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

260
tkeg/web/src/App.css Normal file
View File

@@ -0,0 +1,260 @@
/* 전역 스타일 리셋 */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #2d3748;
background-color: #f7fafc;
}
#root {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
padding: 0;
background: #f7fafc;
}
.page-container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
/* 로딩 스피너 */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f7fafc;
color: #718096;
}
.loading-spinner-large {
width: 48px;
height: 48px;
border: 4px solid #e2e8f0;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid #e2e8f0;
border-top: 2px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.05);
}
}
/* 접근 거부 페이지 */
.access-denied-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f7fafc;
padding: 24px;
}
.access-denied-content {
text-align: center;
max-width: 500px;
background: white;
padding: 48px 32px;
border-radius: 16px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
.access-denied-icon {
font-size: 64px;
margin-bottom: 24px;
}
.access-denied-content h2 {
color: #2d3748;
font-size: 24px;
font-weight: 700;
margin-bottom: 16px;
}
.access-denied-content p {
color: #718096;
font-size: 16px;
margin-bottom: 16px;
line-height: 1.6;
}
.permission-info,
.role-info {
background: #f7fafc;
padding: 12px 16px;
border-radius: 8px;
margin: 16px 0;
font-size: 14px;
}
.permission-info code,
.role-info code {
background: #e2e8f0;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 13px;
color: #2d3748;
}
.user-info {
background: #edf2f7;
padding: 16px;
border-radius: 8px;
margin: 20px 0;
text-align: left;
}
.user-info p {
margin-bottom: 8px;
font-size: 14px;
}
.user-info strong {
color: #2d3748;
}
.back-button {
padding: 12px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
margin-top: 24px;
}
.back-button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
}
/* 유틸리티 클래스 */
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: 8px; }
.mb-2 { margin-bottom: 16px; }
.mb-3 { margin-bottom: 24px; }
.mb-4 { margin-bottom: 32px; }
.mt-0 { margin-top: 0; }
.mt-1 { margin-top: 8px; }
.mt-2 { margin-top: 16px; }
.mt-3 { margin-top: 24px; }
.mt-4 { margin-top: 32px; }
.p-0 { padding: 0; }
.p-1 { padding: 8px; }
.p-2 { padding: 16px; }
.p-3 { padding: 24px; }
.p-4 { padding: 32px; }
/* 반응형 유틸리티 */
.hidden-mobile {
display: block;
}
.hidden-desktop {
display: none;
}
@media (max-width: 768px) {
.hidden-mobile {
display: none;
}
.hidden-desktop {
display: block;
}
.page-container {
padding: 16px;
}
}
/* 스크롤바 스타일링 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e0;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a0aec0;
}
/* 포커스 스타일 */
*:focus {
outline: 2px solid #667eea;
outline-offset: 2px;
}
button:focus,
input:focus,
select:focus,
textarea:focus {
outline: 2px solid #667eea;
outline-offset: 2px;
}
/* 선택 스타일 */
::selection {
background: rgba(102, 126, 234, 0.2);
color: #2d3748;
}

84
tkeg/web/src/api.js Normal file
View File

@@ -0,0 +1,84 @@
import axios from 'axios';
import { config } from './config';
const API_BASE_URL = '/api';
export const api = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
});
// SSO 쿠키에서 토큰 읽기
function getSSOToken() {
const match = document.cookie.match(/sso_token=([^;]*)/);
return match ? match[1] : null;
}
// 요청 인터셉터: SSO 토큰 자동 추가
api.interceptors.request.use(config => {
const token = getSSOToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 응답 인터셉터: 401 시 SSO 로그인 리다이렉트
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
window.location.href = config.ssoLoginUrl(window.location.href);
return new Promise(() => {}); // 리다이렉트 중 pending
}
return Promise.reject(error);
}
);
// 파일 업로드
export function uploadFile(formData, options = {}) {
return api.post('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
...options,
});
}
export function fetchMaterials(params) { return api.get('/files/materials-v2', { params }); }
export function fetchMaterialsSummary(params) { return api.get('/files/materials/summary', { params }); }
export function fetchFiles(params) { return api.get('/files', { params }); }
export function deleteFile(fileId) { return api.delete(`/files/delete/${fileId}`); }
export function fetchJobs(params) { return api.get('/jobs/', { params }); }
export function createJob(data) { return api.post('/jobs/', data); }
export function compareRevisions(jobNo, filename, oldRevision, newRevision) {
return api.get('/files/materials/compare-revisions', {
params: { job_no: jobNo, filename, old_revision: oldRevision, new_revision: newRevision }
});
}
export function compareMaterialRevisions(jobNo, currentRevision, previousRevision = null, saveResult = true) {
return api.post('/materials/compare-revisions', null, {
params: { job_no: jobNo, current_revision: currentRevision, previous_revision: previousRevision, save_result: saveResult }
});
}
export function getMaterialComparisonHistory(jobNo, limit = 10) {
return api.get('/materials/comparison-history', { params: { job_no: jobNo, limit } });
}
export function getMaterialInventoryStatus(jobNo, materialHash = null) {
return api.get('/materials/inventory-status', { params: { job_no: jobNo, material_hash: materialHash } });
}
export function confirmMaterialPurchase(jobNo, revision, confirmations, confirmedBy = 'user') {
return api.post('/materials/confirm-purchase', confirmations, {
params: { job_no: jobNo, revision, confirmed_by: confirmedBy }
});
}
export function getMaterialPurchaseStatus(jobNo, revision = null, status = null) {
return api.get('/materials/purchase-status', { params: { job_no: jobNo, revision, status } });
}
export default api;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

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;

25
tkeg/web/src/config.js Normal file
View File

@@ -0,0 +1,25 @@
/**
* 환경별 URL 자동 감지 설정
* hostname 기반으로 프로덕션/로컬 자동 분기
*/
const hostname = window.location.hostname;
const protocol = window.location.protocol;
const isProd = hostname.includes('technicalkorea.net');
export const config = {
/** SSO 로그인 리다이렉트 URL */
ssoLoginUrl: (redirect) => {
const base = isProd
? `${protocol}//tkds.technicalkorea.net/dashboard`
: `${protocol}//${hostname}:30000/dashboard`;
return redirect ? `${base}?redirect=${encodeURIComponent(redirect)}` : base;
},
/** 쿠키 삭제 시 도메인 옵션 (프로덕션: .technicalkorea.net, 로컬: 없음) */
cookieDomain: isProd ? '; domain=.technicalkorea.net' : '',
/** tkuser 계정 관리 페이지 URL */
tkuserUrl: isProd
? 'https://tkuser.technicalkorea.net'
: `${protocol}//${hostname}:30380`,
};

View File

@@ -0,0 +1,318 @@
/**
* 리비전 관리 훅
* - 리비전 세션 생성, 관리, 완료
* - 자재 비교 및 변경사항 처리
* - 리비전 히스토리 조회
*/
import { useState, useCallback } from 'react';
import api from '../api';
export const useRevisionManagement = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [currentSession, setCurrentSession] = useState(null);
const [sessionStatus, setSessionStatus] = useState(null);
const [revisionHistory, setRevisionHistory] = useState([]);
// 에러 처리 헬퍼
const handleError = useCallback((error, defaultMessage) => {
console.error(defaultMessage, error);
const errorMessage = error.response?.data?.detail || error.message || defaultMessage;
setError(errorMessage);
return { success: false, error: errorMessage };
}, []);
// 리비전 세션 생성
const createRevisionSession = useCallback(async (jobNo, currentFileId, previousFileId) => {
setLoading(true);
setError(null);
try {
const response = await api.post('/revision-management/sessions', {
job_no: jobNo,
current_file_id: currentFileId,
previous_file_id: previousFileId
});
if (response.data.success) {
setCurrentSession(response.data.data);
console.log('✅ 리비전 세션 생성 완료:', response.data.data);
return { success: true, data: response.data.data };
} else {
return handleError(new Error(response.data.message), '리비전 세션 생성 실패');
}
} catch (error) {
return handleError(error, '리비전 세션 생성 중 오류 발생');
} finally {
setLoading(false);
}
}, [handleError]);
// 세션 상태 조회
const getSessionStatus = useCallback(async (sessionId) => {
setLoading(true);
setError(null);
try {
const response = await api.get(`/revision-management/sessions/${sessionId}`);
if (response.data.success) {
setSessionStatus(response.data.data);
console.log('✅ 세션 상태 조회 완료:', response.data.data);
return { success: true, data: response.data.data };
} else {
return handleError(new Error(response.data.message), '세션 상태 조회 실패');
}
} catch (error) {
return handleError(error, '세션 상태 조회 중 오류 발생');
} finally {
setLoading(false);
}
}, [handleError]);
// 카테고리별 자재 비교
const compareCategory = useCallback(async (sessionId, category) => {
setLoading(true);
setError(null);
try {
const response = await api.post(`/revision-management/sessions/${sessionId}/compare/${category}`);
if (response.data.success) {
console.log(`✅ 카테고리 ${category} 비교 완료:`, response.data.data);
return { success: true, data: response.data.data };
} else {
return handleError(new Error(response.data.message), `카테고리 ${category} 비교 실패`);
}
} catch (error) {
return handleError(error, `카테고리 ${category} 비교 중 오류 발생`);
} finally {
setLoading(false);
}
}, [handleError]);
// 세션 변경사항 조회
const getSessionChanges = useCallback(async (sessionId, category = null) => {
setLoading(true);
setError(null);
try {
const params = category ? { category } : {};
const response = await api.get(`/revision-management/sessions/${sessionId}/changes`, { params });
if (response.data.success) {
console.log('✅ 세션 변경사항 조회 완료:', response.data.data);
return { success: true, data: response.data.data };
} else {
return handleError(new Error(response.data.message), '세션 변경사항 조회 실패');
}
} catch (error) {
return handleError(error, '세션 변경사항 조회 중 오류 발생');
} finally {
setLoading(false);
}
}, [handleError]);
// 리비전 액션 처리
const processRevisionAction = useCallback(async (changeId, action, notes = null) => {
setLoading(true);
setError(null);
try {
const response = await api.post(`/revision-management/changes/${changeId}/process`, {
action,
notes
});
if (response.data.success) {
console.log('✅ 리비전 액션 처리 완료:', response.data.data);
return { success: true, data: response.data.data };
} else {
return handleError(new Error(response.data.message), '리비전 액션 처리 실패');
}
} catch (error) {
return handleError(error, '리비전 액션 처리 중 오류 발생');
} finally {
setLoading(false);
}
}, [handleError]);
// 세션 완료
const completeSession = useCallback(async (sessionId) => {
setLoading(true);
setError(null);
try {
const response = await api.post(`/revision-management/sessions/${sessionId}/complete`);
if (response.data.success) {
setCurrentSession(null);
setSessionStatus(null);
console.log('✅ 리비전 세션 완료:', response.data.data);
return { success: true, data: response.data.data };
} else {
return handleError(new Error(response.data.message), '리비전 세션 완료 실패');
}
} catch (error) {
return handleError(error, '리비전 세션 완료 중 오류 발생');
} finally {
setLoading(false);
}
}, [handleError]);
// 세션 취소
const cancelSession = useCallback(async (sessionId, reason = null) => {
setLoading(true);
setError(null);
try {
const params = reason ? { reason } : {};
const response = await api.post(`/revision-management/sessions/${sessionId}/cancel`, null, { params });
if (response.data.success) {
setCurrentSession(null);
setSessionStatus(null);
console.log('✅ 리비전 세션 취소:', response.data.data);
return { success: true, data: response.data.data };
} else {
return handleError(new Error(response.data.message), '리비전 세션 취소 실패');
}
} catch (error) {
return handleError(error, '리비전 세션 취소 중 오류 발생');
} finally {
setLoading(false);
}
}, [handleError]);
// 리비전 히스토리 조회
const getRevisionHistory = useCallback(async (jobNo) => {
setLoading(true);
setError(null);
try {
const response = await api.get(`/revision-management/history/${jobNo}`);
if (response.data.success) {
setRevisionHistory(response.data.data.history);
console.log('✅ 리비전 히스토리 조회 완료:', response.data.data);
return { success: true, data: response.data.data };
} else {
return handleError(new Error(response.data.message), '리비전 히스토리 조회 실패');
}
} catch (error) {
return handleError(error, '리비전 히스토리 조회 중 오류 발생');
} finally {
setLoading(false);
}
}, [handleError]);
// 리비전 요약 조회
const getRevisionSummary = useCallback(async (sessionId) => {
setLoading(true);
setError(null);
try {
const response = await api.get(`/revision-management/sessions/${sessionId}/summary`);
if (response.data.success) {
console.log('✅ 리비전 요약 조회 완료:', response.data.data);
return { success: true, data: response.data.data };
} else {
return handleError(new Error(response.data.message), '리비전 요약 조회 실패');
}
} catch (error) {
return handleError(error, '리비전 요약 조회 중 오류 발생');
} finally {
setLoading(false);
}
}, [handleError]);
// 지원 카테고리 조회
const getSupportedCategories = useCallback(async () => {
try {
const response = await api.get('/revision-management/categories');
if (response.data.success) {
return { success: true, data: response.data.data.categories };
}
} catch (error) {
console.warn('지원 카테고리 조회 실패:', error);
}
// 기본 카테고리 반환
return {
success: true,
data: [
{ key: "PIPE", name: "배관", description: "파이프 및 배관 자재" },
{ key: "FITTING", name: "피팅", description: "배관 연결 부품" },
{ key: "FLANGE", name: "플랜지", description: "플랜지 및 연결 부품" },
{ key: "VALVE", name: "밸브", description: "각종 밸브류" },
{ key: "GASKET", name: "가스켓", description: "씰링 부품" },
{ key: "BOLT", name: "볼트", description: "체결 부품" },
{ key: "SUPPORT", name: "서포트", description: "지지대 및 구조물" },
{ key: "SPECIAL", name: "특수자재", description: "특수 목적 자재" },
{ key: "UNCLASSIFIED", name: "미분류", description: "분류되지 않은 자재" }
]
};
}, []);
// 리비전 액션 목록 조회
const getRevisionActions = useCallback(async () => {
try {
const response = await api.get('/revision-management/actions');
if (response.data.success) {
return { success: true, data: response.data.data.actions };
}
} catch (error) {
console.warn('리비전 액션 조회 실패:', error);
}
// 기본 액션 반환
return {
success: true,
data: [
{ key: "new_material", name: "신규 자재", description: "새로 추가된 자재" },
{ key: "additional_purchase", name: "추가 구매", description: "구매된 자재의 수량 증가" },
{ key: "inventory_transfer", name: "재고 이관", description: "구매된 자재의 수량 감소 또는 제거" },
{ key: "purchase_cancel", name: "구매 취소", description: "미구매 자재의 제거" },
{ key: "quantity_update", name: "수량 업데이트", description: "미구매 자재의 수량 변경" },
{ key: "maintain", name: "유지", description: "변경사항 없음" }
]
};
}, []);
// 상태 초기화
const resetState = useCallback(() => {
setCurrentSession(null);
setSessionStatus(null);
setRevisionHistory([]);
setError(null);
setLoading(false);
}, []);
return {
// 상태
loading,
error,
currentSession,
sessionStatus,
revisionHistory,
// 액션
createRevisionSession,
getSessionStatus,
compareCategory,
getSessionChanges,
processRevisionAction,
completeSession,
cancelSession,
getRevisionHistory,
getRevisionSummary,
getSupportedCategories,
getRevisionActions,
resetState,
// 유틸리티
clearError: () => setError(null)
};
};

34
tkeg/web/src/index.css Normal file
View File

@@ -0,0 +1,34 @@
body {
margin: 0;
padding: 0;
font-family: 'Roboto', 'Helvetica', 'Arial', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
}
* {
box-sizing: border-box;
}
#root {
min-height: 100vh;
}
/* 스크롤바 스타일링 */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}

View File

@@ -0,0 +1,184 @@
/* BOM Management Page Styles */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.bom-management-page {
padding: 40px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
min-height: 100vh;
}
.bom-header-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 32px;
box-shadow: 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);
margin-bottom: 40px;
}
.bom-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.bom-stat-card {
padding: 20px;
border-radius: 12px;
text-align: center;
transition: transform 0.2s ease;
}
.bom-stat-card:hover {
transform: translateY(-2px);
}
.bom-stat-number {
font-size: 32px;
font-weight: 700;
margin-bottom: 4px;
}
.bom-stat-label {
font-size: 14px;
font-weight: 500;
}
.bom-category-tabs {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 24px 32px;
box-shadow: 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);
margin-bottom: 40px;
}
.bom-category-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 16px;
}
.bom-category-button {
border-radius: 12px;
padding: 16px 12px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s ease;
text-align: center;
border: none;
outline: none;
}
.bom-category-button:hover {
transform: translateY(-1px);
}
.bom-category-icon {
font-size: 20px;
margin-bottom: 8px;
}
.bom-category-count {
font-size: 12px;
opacity: 0.8;
font-weight: 500;
}
.bom-content-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 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);
overflow: hidden;
}
.bom-loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
}
.bom-loading-spinner {
width: 60px;
height: 60px;
border: 4px solid #e2e8f0;
border-top: 4px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
.bom-loading-text {
font-size: 18px;
color: #64748b;
font-weight: 600;
}
.bom-error {
padding: 60px;
text-align: center;
color: #dc2626;
}
.bom-error-icon {
font-size: 48px;
margin-bottom: 16px;
}
.bom-error-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.bom-error-message {
font-size: 14px;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.bom-management-page {
padding: 20px;
}
.bom-header-card {
padding: 24px;
}
.bom-stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.bom-category-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.bom-category-button {
padding: 12px 8px;
font-size: 12px;
}
}
@media (max-width: 480px) {
.bom-stats-grid {
grid-template-columns: 1fr;
}
.bom-category-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,613 @@
import React, { useState, useEffect } from 'react';
import { fetchMaterials } from '../api';
import api from '../api';
import {
PipeMaterialsView,
FittingMaterialsView,
FlangeMaterialsView,
ValveMaterialsView,
GasketMaterialsView,
BoltMaterialsView,
SupportMaterialsView,
SpecialMaterialsView,
UnclassifiedMaterialsView
} from '../components/bom';
import RevisionManagementPanel from '../components/revision/RevisionManagementPanel';
import './BOMManagementPage.css';
const BOMManagementPage = ({
onNavigate,
selectedProject,
fileId,
jobNo,
bomName,
revision,
filename,
user
}) => {
const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedCategory, setSelectedCategory] = useState('PIPE');
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
const [exportHistory, setExportHistory] = useState([]);
const [availableRevisions, setAvailableRevisions] = useState([]);
const [currentRevision, setCurrentRevision] = useState(revision || 'Rev.0');
const [userRequirements, setUserRequirements] = useState({});
const [purchasedMaterials, setPurchasedMaterials] = useState(new Set());
const [error, setError] = useState(null);
// 리비전 관련 상태
const [isRevisionMode, setIsRevisionMode] = useState(false);
const [previousFileId, setPreviousFileId] = useState(null);
const [showRevisionPanel, setShowRevisionPanel] = useState(false);
// 자재 업데이트 함수 (브랜드, 사용자 요구사항 등)
const updateMaterial = (materialId, updates) => {
setMaterials(prevMaterials =>
prevMaterials.map(material =>
material.id === materialId
? { ...material, ...updates }
: material
)
);
};
// 카테고리 정의
const categories = [
{ key: 'PIPE', label: 'Pipes', color: '#3b82f6' },
{ key: 'FITTING', label: 'Fittings', color: '#10b981' },
{ key: 'FLANGE', label: 'Flanges', color: '#f59e0b' },
{ key: 'VALVE', label: 'Valves', color: '#ef4444' },
{ key: 'GASKET', label: 'Gaskets', color: '#8b5cf6' },
{ key: 'BOLT', label: 'Bolts', color: '#6b7280' },
{ key: 'SUPPORT', label: 'Supports', color: '#f97316' },
{ key: 'SPECIAL', label: 'Special Items', color: '#ec4899' },
{ key: 'UNCLASSIFIED', label: 'Unclassified', color: '#64748b' }
];
// 자료 로드 함수들
const loadMaterials = async (id) => {
try {
setLoading(true);
console.log('🔍 자재 데이터 로딩 중...', {
file_id: id,
selectedProject: selectedProject?.job_no || selectedProject?.official_project_code,
jobNo
});
// 구매신청된 자재 먼저 확인
const projectJobNo = selectedProject?.job_no || selectedProject?.official_project_code || jobNo;
await loadPurchasedMaterials(projectJobNo);
const response = await fetchMaterials({
file_id: parseInt(id),
limit: 10000,
exclude_requested: false,
job_no: projectJobNo
});
if (response.data?.materials) {
const materialsData = response.data.materials;
console.log(`${materialsData.length}개 원본 자재 로드 완료`);
setMaterials(materialsData);
setError(null);
} else {
console.warn('⚠️ 자재 데이터가 없습니다:', response.data);
setMaterials([]);
}
} catch (error) {
console.error('자재 로드 실패:', error);
setError('자재 로드에 실패했습니다.');
} finally {
setLoading(false);
}
};
const loadAvailableRevisions = async () => {
try {
const response = await api.get('/files/', {
params: { job_no: jobNo }
});
const allFiles = Array.isArray(response.data) ? response.data : response.data?.files || [];
const sameBomFiles = allFiles.filter(file =>
(file.bom_name || file.original_filename) === bomName
);
sameBomFiles.sort((a, b) => {
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
return revB - revA;
});
setAvailableRevisions(sameBomFiles);
} catch (error) {
console.error('리비전 목록 조회 실패:', error);
}
};
const loadPurchasedMaterials = async (jobNo) => {
try {
// 새로운 API로 구매신청된 자재 ID 목록 조회
const response = await api.get('/purchase-request/requested-materials', {
params: {
job_no: jobNo,
file_id: fileId
}
});
if (response.data?.requested_material_ids) {
const purchasedIds = new Set(response.data.requested_material_ids);
setPurchasedMaterials(purchasedIds);
console.log(`${purchasedIds.size}개 구매신청된 자재 ID 로드 완료`);
}
} catch (error) {
console.error('구매신청 자재 조회 실패:', error);
}
};
const loadUserRequirements = async (fileId) => {
try {
const response = await api.get(`/files/${fileId}/user-requirements`);
if (response.data?.requirements) {
const reqMap = {};
response.data.requirements.forEach(req => {
reqMap[req.material_id] = req.requirement;
});
setUserRequirements(reqMap);
}
} catch (error) {
console.error('사용자 요구사항 로드 실패:', error);
}
};
// 초기 로드
useEffect(() => {
if (fileId) {
loadMaterials(fileId);
loadAvailableRevisions();
loadUserRequirements(fileId);
checkRevisionMode(); // 리비전 모드 확인
}
}, [fileId]);
// 리비전 모드 확인
const checkRevisionMode = async () => {
try {
// 현재 job_no의 모든 파일 목록 확인
const response = await api.get(`/files/list?job_no=${jobNo}`);
const files = response.data.files || [];
if (files.length > 1) {
// 파일들을 업로드 날짜순으로 정렬
const sortedFiles = files.sort((a, b) => new Date(a.upload_date) - new Date(b.upload_date));
// 현재 파일의 인덱스 찾기
const currentIndex = sortedFiles.findIndex(file => file.id === parseInt(fileId));
if (currentIndex > 0) {
// 이전 파일이 있으면 리비전 모드 활성화
const previousFile = sortedFiles[currentIndex - 1];
setIsRevisionMode(true);
setPreviousFileId(previousFile.id);
console.log('✅ 리비전 모드 활성화:', {
currentFileId: fileId,
previousFileId: previousFile.id,
currentRevision: revision,
previousRevision: previousFile.revision
});
}
}
} catch (error) {
console.error('리비전 모드 확인 실패:', error);
}
};
// 리비전 관리 핸들러
const handleRevisionComplete = (revisionData) => {
console.log('✅ 리비전 완료:', revisionData);
setShowRevisionPanel(false);
setIsRevisionMode(false);
// 자재 목록 새로고침
loadMaterials(fileId);
// 성공 메시지 표시
alert('리비전 처리가 완료되었습니다!');
};
const handleRevisionCancel = (cancelData) => {
console.log('❌ 리비전 취소:', cancelData);
setShowRevisionPanel(false);
// 취소 메시지 표시
alert('리비전 처리가 취소되었습니다.');
};
// 자재 로드 후 선택된 카테고리가 유효한지 확인
useEffect(() => {
if (materials.length > 0) {
const availableCategories = categories.filter(category => {
const count = getCategoryMaterials(category.key).length;
return count > 0;
});
// 현재 선택된 카테고리에 자재가 없으면 첫 번째 유효한 카테고리로 전환
const currentCategoryHasMaterials = getCategoryMaterials(selectedCategory).length > 0;
if (!currentCategoryHasMaterials && availableCategories.length > 0) {
setSelectedCategory(availableCategories[0].key);
}
}
}, [materials, selectedCategory]);
// 카테고리별 자재 필터링
const getCategoryMaterials = (category) => {
return materials.filter(material =>
material.classified_category === category ||
material.category === category
);
};
// 카테고리별 컴포넌트 렌더링
const renderCategoryView = () => {
const categoryMaterials = getCategoryMaterials(selectedCategory);
const commonProps = {
materials: categoryMaterials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate: (materialIds) => {
setPurchasedMaterials(prev => {
const newSet = new Set(prev);
materialIds.forEach(id => newSet.add(id));
console.log(`📦 구매신청 자재 추가: 기존 ${prev.size}개 → 신규 ${newSet.size}`);
return newSet;
});
},
updateMaterial, // 자재 업데이트 함수 추가
fileId,
jobNo,
user,
onNavigate
};
switch (selectedCategory) {
case 'PIPE':
return <PipeMaterialsView {...commonProps} />;
case 'FITTING':
return <FittingMaterialsView {...commonProps} />;
case 'FLANGE':
return <FlangeMaterialsView {...commonProps} />;
case 'VALVE':
return <ValveMaterialsView {...commonProps} />;
case 'GASKET':
return <GasketMaterialsView {...commonProps} />;
case 'BOLT':
return <BoltMaterialsView {...commonProps} />;
case 'SUPPORT':
return <SupportMaterialsView {...commonProps} />;
case 'SPECIAL':
return <SpecialMaterialsView {...commonProps} />;
case 'UNCLASSIFIED':
return <UnclassifiedMaterialsView {...commonProps} />;
default:
return <div>카테고리를 선택해주세요.</div>;
}
};
if (loading) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)'
}}>
<div style={{ textAlign: 'center' }}>
<div style={{
width: '60px',
height: '60px',
border: '4px solid #e2e8f0',
borderTop: '4px solid #3b82f6',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto 20px'
}}></div>
<div style={{ fontSize: '18px', color: '#64748b', fontWeight: '600' }}>
Loading Materials...
</div>
</div>
</div>
);
}
return (
<div style={{
padding: '40px',
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
minHeight: '100vh'
}}>
{/* 헤더 섹션 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
padding: '32px',
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)',
marginBottom: '40px'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '28px' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<h2 style={{
fontSize: '28px',
fontWeight: '700',
color: '#0f172a',
margin: 0,
letterSpacing: '-0.025em'
}}>
BOM Materials Management
</h2>
{isRevisionMode && (
<div style={{
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
color: 'white',
padding: '6px 12px',
borderRadius: '8px',
fontSize: '12px',
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: '0.05em',
boxShadow: '0 4px 6px -1px rgba(245, 158, 11, 0.3)'
}}>
📊 Revision Mode
</div>
)}
</div>
<p style={{
fontSize: '16px',
color: '#64748b',
margin: 0,
fontWeight: '400'
}}>
{bomName} - {currentRevision} | Project: {selectedProject?.job_name || jobNo}
</p>
</div>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
{isRevisionMode && (
<button
onClick={() => setShowRevisionPanel(!showRevisionPanel)}
style={{
background: showRevisionPanel
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
color: 'white',
border: 'none',
borderRadius: '12px',
padding: '12px 20px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600',
transition: 'all 0.2s ease',
letterSpacing: '0.025em',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
}}
>
{showRevisionPanel ? '📊 Hide Revision Panel' : '🔄 Manage Revision'}
</button>
)}
<button
onClick={() => onNavigate('dashboard')}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '12px',
padding: '12px 20px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600',
transition: 'all 0.2s ease',
letterSpacing: '0.025em'
}}
onMouseEnter={(e) => {
e.target.style.background = '#f9fafb';
e.target.style.borderColor = '#9ca3af';
}}
onMouseLeave={(e) => {
e.target.style.background = 'white';
e.target.style.borderColor = '#d1d5db';
}}
>
Back to Dashboard
</button>
</div>
</div>
{/* 통계 정보 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '20px',
marginBottom: '32px'
}}>
<div style={{
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#1d4ed8', marginBottom: '4px' }}>
{materials.length}
</div>
<div style={{ fontSize: '14px', color: '#1d4ed8', fontWeight: '500' }}>
Total Materials
</div>
</div>
<div style={{
background: 'linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#059669', marginBottom: '4px' }}>
{getCategoryMaterials(selectedCategory).length}
</div>
<div style={{ fontSize: '14px', color: '#059669', fontWeight: '500' }}>
{selectedCategory} Items
</div>
</div>
<div style={{
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#92400e', marginBottom: '4px' }}>
{selectedMaterials.size}
</div>
<div style={{ fontSize: '14px', color: '#92400e', fontWeight: '500' }}>
Selected
</div>
</div>
<div style={{
background: 'linear-gradient(135deg, #fee2e2 0%, #fecaca 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#dc2626', marginBottom: '4px' }}>
{purchasedMaterials.size}
</div>
<div style={{ fontSize: '14px', color: '#dc2626', fontWeight: '500' }}>
Purchased
</div>
</div>
</div>
</div>
{/* 카테고리 탭 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
padding: '24px 32px',
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)',
marginBottom: '40px'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: '16px'
}}>
{categories
.filter((category) => {
const count = getCategoryMaterials(category.key).length;
return count > 0; // 0개인 카테고리는 숨김
})
.map((category) => {
const isActive = selectedCategory === category.key;
const count = getCategoryMaterials(category.key).length;
return (
<button
key={category.key}
onClick={() => setSelectedCategory(category.key)}
style={{
background: isActive
? `linear-gradient(135deg, ${category.color} 0%, ${category.color}dd 100%)`
: 'white',
color: isActive ? 'white' : '#64748b',
border: isActive ? 'none' : '1px solid #e2e8f0',
borderRadius: '12px',
padding: '16px 12px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600',
transition: 'all 0.2s ease',
textAlign: 'center',
boxShadow: isActive ? `0 4px 14px 0 ${category.color}39` : '0 2px 8px rgba(0,0,0,0.05)'
}}
onMouseEnter={(e) => {
if (!isActive) {
e.target.style.background = '#f8fafc';
e.target.style.borderColor = '#cbd5e1';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.target.style.background = 'white';
e.target.style.borderColor = '#e2e8f0';
}
}}
>
<div style={{ marginBottom: '4px' }}>
{category.label}
</div>
<div style={{
fontSize: '12px',
opacity: 0.8,
fontWeight: '500'
}}>
{count} items
</div>
</button>
);
})}
</div>
</div>
{/* 카테고리별 컨텐츠 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
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)',
overflow: 'hidden'
}}>
{error ? (
<div style={{
padding: '60px',
textAlign: 'center',
color: '#dc2626'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
Error Loading Materials
</div>
<div style={{ fontSize: '14px' }}>
{error}
</div>
</div>
) : (
renderCategoryView()
)}
</div>
{/* 리비전 관리 패널 */}
{isRevisionMode && showRevisionPanel && (
<div style={{ marginTop: '40px' }}>
<RevisionManagementPanel
jobNo={jobNo}
currentFileId={parseInt(fileId)}
previousFileId={previousFileId}
onRevisionComplete={handleRevisionComplete}
onRevisionCancel={handleRevisionCancel}
/>
</div>
)}
</div>
);
};
export default BOMManagementPage;

View File

@@ -0,0 +1,300 @@
import React, { useState, useEffect } from 'react';
import { api, fetchFiles, deleteFile as deleteFileApi } from '../api';
import BOMFileUpload from '../components/BOMFileUpload';
const BOMStatusPage = ({ jobNo, jobName, onNavigate, selectedProject }) => {
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const [bomName, setBomName] = useState('');
useEffect(() => {
if (jobNo) {
fetchFilesList();
}
}, [jobNo]);
const fetchFilesList = async () => {
try {
setLoading(true);
const response = await api.get('/files/', {
params: { job_no: jobNo }
});
// API가 배열로 직접 반환하는 경우
if (Array.isArray(response.data)) {
setFiles(response.data);
} else if (response.data && response.data.success) {
setFiles(response.data.files || []);
} else {
setFiles([]);
}
} catch (err) {
console.error('파일 목록 로딩 실패:', err);
setError('파일 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
// 파일 업로드
const handleUpload = async () => {
if (!selectedFile || !bomName.trim()) {
alert('파일과 BOM 이름을 모두 입력해주세요.');
return;
}
try {
setUploading(true);
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('bom_name', bomName.trim());
formData.append('job_no', jobNo);
const response = await api.post('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (response.data && response.data.success) {
alert('파일이 성공적으로 업로드되었습니다!');
setSelectedFile(null);
setBomName('');
await fetchFilesList(); // 목록 새로고침
} else {
throw new Error(response.data?.message || '업로드 실패');
}
} catch (err) {
console.error('파일 업로드 실패:', err);
setError('파일 업로드에 실패했습니다.');
} finally {
setUploading(false);
}
};
// 파일 삭제
const handleDelete = async (fileId) => {
if (!window.confirm('정말로 이 파일을 삭제하시겠습니까?')) {
return;
}
try {
await deleteFileApi(fileId);
await fetchFilesList(); // 목록 새로고침
} catch (err) {
console.error('파일 삭제 실패:', err);
setError('파일 삭제에 실패했습니다.');
}
};
// 자재 관리 페이지로 바로 이동 (단순화)
const handleViewMaterials = (file) => {
if (onNavigate) {
onNavigate('materials', {
file_id: file.id,
jobNo: file.job_no,
bomName: file.bom_name || file.original_filename,
revision: file.revision,
filename: file.original_filename
});
}
};
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ marginBottom: '24px' }}>
<button
onClick={() => {
if (onNavigate) {
onNavigate('dashboard');
}
}}
style={{
padding: '8px 16px',
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: '6px',
cursor: 'pointer',
marginBottom: '16px'
}}
>
메인으로 돌아가기
</button>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
📊 BOM 관리 시스템
</h1>
{jobNo && jobName && (
<h2 style={{
fontSize: '20px',
fontWeight: '600',
color: '#4299e1',
margin: '0 0 24px 0'
}}>
{jobNo} - {jobName}
</h2>
)}
</div>
{/* 파일 업로드 컴포넌트 */}
<BOMFileUpload
bomName={bomName}
setBomName={setBomName}
selectedFile={selectedFile}
setSelectedFile={setSelectedFile}
uploading={uploading}
handleUpload={handleUpload}
error={error}
/>
{/* BOM 목록 */}
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '32px 0 16px 0'
}}>
업로드된 BOM 목록
</h3>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
로딩 ...
</div>
) : (
<div style={{
background: 'white',
borderRadius: '12px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.07)',
overflow: 'hidden'
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f7fafc' }}>
<th style={{ padding: '16px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>BOM 이름</th>
<th style={{ padding: '16px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>파일명</th>
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>리비전</th>
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>자재 </th>
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>업로드 일시</th>
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>작업</th>
</tr>
</thead>
<tbody>
{files.map((file) => (
<tr key={file.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
<td style={{ padding: '16px' }}>
<div style={{ fontWeight: '600', color: '#2d3748' }}>
{file.bom_name || file.original_filename}
</div>
<div style={{ fontSize: '12px', color: '#718096' }}>
{file.description || ''}
</div>
</td>
<td style={{ padding: '16px', fontSize: '14px', color: '#4a5568' }}>
{file.original_filename}
</td>
<td style={{ padding: '16px', textAlign: 'center' }}>
<span style={{
background: '#e6fffa',
color: '#065f46',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '600'
}}>
{file.revision || 'Rev.0'}
</span>
</td>
<td style={{ padding: '16px', textAlign: 'center' }}>
{file.parsed_count || 0}
</td>
<td style={{ padding: '16px', textAlign: 'center', fontSize: '14px', color: '#718096' }}>
{new Date(file.created_at).toLocaleDateString()}
</td>
<td style={{ padding: '16px', textAlign: 'center' }}>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
<button
onClick={() => handleViewMaterials(file)}
style={{
padding: '6px 12px',
background: '#4299e1',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '600'
}}
>
📋 자재 보기
</button>
<button
onClick={() => {
// 리비전 업로드 기능 (추후 구현)
alert('리비전 업로드 기능은 준비 중입니다.');
}}
style={{
padding: '6px 12px',
background: 'white',
color: '#4299e1',
border: '1px solid #4299e1',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '600'
}}
>
📝 리비전
</button>
<button
onClick={() => handleDelete(file.id)}
style={{
padding: '6px 12px',
background: '#f56565',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '600'
}}
>
🗑 삭제
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{files.length === 0 && (
<div style={{
padding: '40px',
textAlign: 'center',
color: '#718096'
}}>
업로드된 BOM 파일이 없습니다.
</div>
)}
</div>
)}
</div>
</div>
);
};
export default BOMStatusPage;

View File

@@ -0,0 +1,398 @@
import React, { useState } from 'react';
const InactiveProjectsPage = ({
onNavigate,
user,
projects,
inactiveProjects,
onActivateProject,
onDeleteProject
}) => {
const [selectedProjects, setSelectedProjects] = useState(new Set());
// 비활성 프로젝트 목록 필터링
const inactiveProjectList = projects.filter(project =>
inactiveProjects.has(project.job_no)
);
// 프로젝트 선택/해제
const handleProjectSelect = (projectNo) => {
setSelectedProjects(prev => {
const newSet = new Set(prev);
if (newSet.has(projectNo)) {
newSet.delete(projectNo);
} else {
newSet.add(projectNo);
}
return newSet;
});
};
// 전체 선택/해제
const handleSelectAll = () => {
if (selectedProjects.size === inactiveProjectList.length) {
setSelectedProjects(new Set());
} else {
setSelectedProjects(new Set(inactiveProjectList.map(p => p.job_no)));
}
};
// 선택된 프로젝트들 활성화
const handleBulkActivate = () => {
if (selectedProjects.size === 0) {
alert('활성화할 프로젝트를 선택해주세요.');
return;
}
if (window.confirm(`선택된 ${selectedProjects.size}개 프로젝트를 활성화하시겠습니까?`)) {
selectedProjects.forEach(projectNo => {
const project = projects.find(p => p.job_no === projectNo);
if (project) {
onActivateProject(project);
}
});
setSelectedProjects(new Set());
}
};
// 선택된 프로젝트들 삭제
const handleBulkDelete = () => {
if (selectedProjects.size === 0) {
alert('삭제할 프로젝트를 선택해주세요.');
return;
}
if (window.confirm(`선택된 ${selectedProjects.size}개 프로젝트를 완전히 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.`)) {
selectedProjects.forEach(projectNo => {
onDeleteProject(projectNo);
});
setSelectedProjects(new Set());
}
};
return (
<div style={{
padding: '40px',
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
minHeight: '100vh'
}}>
{/* 헤더 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
padding: '32px',
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)',
marginBottom: '40px'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '28px' }}>
<div>
<h2 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 4px 0',
letterSpacing: '-0.025em'
}}>
Inactive Projects Management
</h2>
<p style={{
fontSize: '14px',
color: '#64748b',
margin: 0,
fontWeight: '400'
}}>
Manage deactivated projects - activate or permanently delete
</p>
</div>
<button
onClick={() => onNavigate('dashboard')}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '12px',
padding: '12px 20px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600',
transition: 'all 0.2s ease',
letterSpacing: '0.025em'
}}
onMouseEnter={(e) => {
e.target.style.background = '#f9fafb';
e.target.style.borderColor = '#9ca3af';
}}
onMouseLeave={(e) => {
e.target.style.background = 'white';
e.target.style.borderColor = '#d1d5db';
}}
>
Back to Dashboard
</button>
</div>
{/* 통계 정보 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '20px',
marginBottom: '32px'
}}>
<div style={{
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '28px', fontWeight: '700', color: '#92400e', marginBottom: '4px' }}>
{inactiveProjectList.length}
</div>
<div style={{ fontSize: '14px', color: '#92400e', fontWeight: '500' }}>
Inactive Projects
</div>
</div>
<div style={{
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '28px', fontWeight: '700', color: '#1d4ed8', marginBottom: '4px' }}>
{selectedProjects.size}
</div>
<div style={{ fontSize: '14px', color: '#1d4ed8', fontWeight: '500' }}>
Selected
</div>
</div>
</div>
{/* 일괄 작업 버튼들 */}
{inactiveProjectList.length > 0 && (
<div style={{ display: 'flex', gap: '12px', marginBottom: '24px' }}>
<button
onClick={handleSelectAll}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '8px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
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';
}}
>
{selectedProjects.size === inactiveProjectList.length ? 'Deselect All' : 'Select All'}
</button>
<button
onClick={handleBulkActivate}
disabled={selectedProjects.size === 0}
style={{
background: selectedProjects.size > 0 ? 'linear-gradient(135deg, #10b981 0%, #059669 100%)' : '#e5e7eb',
color: selectedProjects.size > 0 ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '8px',
padding: '10px 16px',
cursor: selectedProjects.size > 0 ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500',
transition: 'all 0.2s ease'
}}
>
Activate Selected ({selectedProjects.size})
</button>
<button
onClick={handleBulkDelete}
disabled={selectedProjects.size === 0}
style={{
background: selectedProjects.size > 0 ? 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' : '#e5e7eb',
color: selectedProjects.size > 0 ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '8px',
padding: '10px 16px',
cursor: selectedProjects.size > 0 ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500',
transition: 'all 0.2s ease'
}}
>
Delete Selected ({selectedProjects.size})
</button>
</div>
)}
</div>
{/* 비활성 프로젝트 목록 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
padding: '32px',
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)'
}}>
<h3 style={{
fontSize: '20px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 24px 0',
letterSpacing: '-0.025em'
}}>
Inactive Projects List
</h3>
{inactiveProjectList.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#64748b'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📂</div>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
No Inactive Projects
</div>
<div style={{ fontSize: '14px' }}>
All projects are currently active
</div>
</div>
) : (
<div style={{
display: 'grid',
gap: '16px'
}}>
{inactiveProjectList.map((project) => (
<div
key={project.job_no}
style={{
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: '12px',
padding: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
transition: 'all 0.2s ease',
boxShadow: '0 2px 8px rgba(0,0,0,0.05)'
}}
onMouseEnter={(e) => {
e.target.style.borderColor = '#cbd5e1';
e.target.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
}}
onMouseLeave={(e) => {
e.target.style.borderColor = '#e2e8f0';
e.target.style.boxShadow = '0 2px 8px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', flex: 1 }}>
<input
type="checkbox"
checked={selectedProjects.has(project.job_no)}
onChange={() => handleProjectSelect(project.job_no)}
style={{
width: '18px',
height: '18px',
cursor: 'pointer'
}}
/>
<div style={{ flex: 1 }}>
<div style={{
fontSize: '18px',
fontWeight: '600',
color: '#1a202c',
marginBottom: '4px'
}}>
{project.job_name || project.job_no}
</div>
<div style={{
fontSize: '14px',
color: '#64748b'
}}>
Code: {project.job_no} | Client: {project.client_name || 'N/A'}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={() => {
if (window.confirm(`"${project.job_name || project.job_no}" 프로젝트를 활성화하시겠습니까?`)) {
onActivateProject(project);
}
}}
style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
color: 'white',
border: 'none',
borderRadius: '8px',
padding: '8px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.target.style.transform = 'translateY(-1px)';
e.target.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.4)';
}}
onMouseLeave={(e) => {
e.target.style.transform = 'translateY(0)';
e.target.style.boxShadow = 'none';
}}
>
Activate
</button>
<button
onClick={() => {
if (window.confirm(`"${project.job_name || project.job_no}" 프로젝트를 완전히 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.`)) {
onDeleteProject(project.job_no);
}
}}
style={{
background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
color: 'white',
border: 'none',
borderRadius: '8px',
padding: '8px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.target.style.transform = 'translateY(-1px)';
e.target.style.boxShadow = '0 4px 12px rgba(239, 68, 68, 0.4)';
}}
onMouseLeave={(e) => {
e.target.style.transform = 'translateY(0)';
e.target.style.boxShadow = 'none';
}}
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default InactiveProjectsPage;

View File

@@ -0,0 +1,334 @@
.job-registration-page {
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
padding: 20px;
}
.job-registration-container {
max-width: 1000px;
margin: 0 auto;
background: white;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px 40px;
position: relative;
}
.back-button {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s ease;
margin-bottom: 20px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
}
.page-header h1 {
font-size: 2rem;
margin: 0 0 10px 0;
font-weight: 600;
}
.page-header p {
font-size: 1.1rem;
margin: 0;
opacity: 0.9;
}
.registration-form {
padding: 40px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 25px;
margin-bottom: 40px;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-group label {
font-weight: 600;
color: #2d3748;
margin-bottom: 8px;
font-size: 0.95rem;
}
.form-group label.required::after {
content: ' *';
color: #e53e3e;
}
.form-group input,
.form-group select,
.form-group textarea {
padding: 12px 16px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
transition: all 0.3s ease;
background: white;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-group input.error,
.form-group select.error,
.form-group textarea.error {
border-color: #e53e3e;
}
.form-group input::placeholder,
.form-group textarea::placeholder {
color: #a0aec0;
}
.form-group textarea {
resize: vertical;
min-height: 100px;
font-family: inherit;
}
.error-message {
color: #e53e3e;
font-size: 0.85rem;
margin-top: 5px;
font-weight: 500;
}
.form-actions {
display: flex;
gap: 15px;
justify-content: flex-end;
padding-top: 30px;
border-top: 1px solid #e2e8f0;
}
.cancel-button,
.submit-button {
padding: 12px 24px;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
border: none;
min-width: 120px;
}
.cancel-button {
background: #f7fafc;
color: #4a5568;
border: 2px solid #e2e8f0;
}
.cancel-button:hover {
background: #edf2f7;
border-color: #cbd5e0;
}
.submit-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: 2px solid transparent;
}
.submit-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.submit-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* 모바일 반응형 */
@media (max-width: 768px) {
.job-registration-page {
padding: 10px;
}
.registration-form {
padding: 25px 20px;
}
.page-header {
padding: 25px 20px;
}
.page-header h1 {
font-size: 1.6rem;
}
.form-grid {
grid-template-columns: 1fr;
gap: 20px;
}
.form-actions {
flex-direction: column-reverse;
}
.cancel-button,
.submit-button {
width: 100%;
}
}
/* 프로젝트 유형 관리 스타일 */
.project-type-container {
display: flex;
gap: 8px;
align-items: center;
}
.project-type-container select {
flex: 1;
}
.project-type-actions {
display: flex;
gap: 4px;
}
.add-type-btn,
.remove-type-btn {
width: 32px;
height: 32px;
border: 2px solid #e2e8f0;
background: white;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
font-weight: bold;
transition: all 0.2s ease;
}
.add-type-btn {
color: #38a169;
border-color: #38a169;
}
.add-type-btn:hover {
background: #38a169;
color: white;
}
.remove-type-btn {
color: #e53e3e;
border-color: #e53e3e;
}
.remove-type-btn:hover {
background: #e53e3e;
color: white;
}
.add-project-type-form {
display: flex;
gap: 8px;
margin-top: 8px;
padding: 12px;
background: #f7fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.add-project-type-form input {
flex: 1;
padding: 8px 12px;
border: 1px solid #cbd5e0;
border-radius: 4px;
font-size: 0.9rem;
}
.add-project-type-form button {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.add-project-type-form button:first-of-type {
background: #38a169;
color: white;
}
.add-project-type-form button:first-of-type:hover {
background: #2f855a;
}
.add-project-type-form button:last-of-type {
background: #e2e8f0;
color: #4a5568;
}
.add-project-type-form button:last-of-type:hover {
background: #cbd5e0;
}
/* 태블릿 반응형 */
@media (max-width: 1024px) and (min-width: 769px) {
.job-registration-container {
margin: 20px;
max-width: none;
}
}
/* 모바일에서 프로젝트 유형 관리 */
@media (max-width: 768px) {
.project-type-container {
flex-direction: column;
align-items: stretch;
}
.project-type-actions {
justify-content: center;
margin-top: 8px;
}
.add-project-type-form {
flex-direction: column;
}
.add-project-type-form button {
width: 100%;
}
}

View File

@@ -0,0 +1,359 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api';
import './JobRegistrationPage.css';
const JobRegistrationPage = () => {
const navigate = useNavigate();
const [formData, setFormData] = useState({
jobNo: '',
projectName: '',
clientName: '',
location: '',
contractDate: '',
deliveryDate: '',
deliveryMethod: '',
description: '',
projectType: '냉동기',
status: 'PLANNING'
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState({});
const [projectTypes, setProjectTypes] = useState([
{ value: '냉동기', label: '냉동기' },
{ value: 'BOG', label: 'BOG' },
{ value: '다이아프람', label: '다이아프람' },
{ value: '드라이어', label: '드라이어' }
]);
const [newProjectType, setNewProjectType] = useState('');
const [showAddProjectType, setShowAddProjectType] = useState(false);
const statusOptions = [
{ value: 'PLANNING', label: '계획' },
{ value: 'DESIGN', label: '설계' },
{ value: 'PROCUREMENT', label: '조달' },
{ value: 'CONSTRUCTION', label: '시공' },
{ value: 'COMPLETED', label: '완료' }
];
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// 입력 시 에러 제거
if (errors[name]) {
setErrors(prev => ({
...prev,
[name]: ''
}));
}
};
const addProjectType = () => {
if (newProjectType.trim() && !projectTypes.find(type => type.value === newProjectType.trim())) {
const newType = { value: newProjectType.trim(), label: newProjectType.trim() };
setProjectTypes(prev => [...prev, newType]);
setFormData(prev => ({ ...prev, projectType: newProjectType.trim() }));
setNewProjectType('');
setShowAddProjectType(false);
}
};
const removeProjectType = (valueToRemove) => {
if (projectTypes.length > 1) { // 최소 1개는 유지
setProjectTypes(prev => prev.filter(type => type.value !== valueToRemove));
if (formData.projectType === valueToRemove) {
setFormData(prev => ({ ...prev, projectType: projectTypes[0].value }));
}
}
};
const validateForm = () => {
const newErrors = {};
if (!formData.jobNo.trim()) {
newErrors.jobNo = 'Job No.는 필수 입력 항목입니다.';
}
if (!formData.projectName.trim()) {
newErrors.projectName = '프로젝트명은 필수 입력 항목입니다.';
}
if (!formData.clientName.trim()) {
newErrors.clientName = '고객사명은 필수 입력 항목입니다.';
}
if (formData.contractDate && formData.deliveryDate && new Date(formData.contractDate) > new Date(formData.deliveryDate)) {
newErrors.deliveryDate = '납기일은 수주일 이후여야 합니다.';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setLoading(true);
try {
// Job 생성 API 호출
const response = await api.post('/jobs', {
job_no: formData.jobNo,
job_name: formData.projectName,
client_name: formData.clientName,
project_site: formData.location || null,
contract_date: formData.contractDate || null,
delivery_date: formData.deliveryDate || null,
delivery_terms: formData.deliveryMethod || null,
description: formData.description || null,
project_type: formData.projectType,
status: formData.status
});
if (response.data.success) {
alert('프로젝트가 성공적으로 등록되었습니다!');
navigate('/project-selection');
} else {
alert('등록에 실패했습니다: ' + response.data.message);
}
} catch (error) {
console.error('Job 등록 오류:', error);
if (error.response?.data?.detail) {
alert('등록 실패: ' + error.response.data.detail);
} else {
alert('등록 중 오류가 발생했습니다.');
}
} finally {
setLoading(false);
}
};
return (
<div className="job-registration-page">
<div className="job-registration-container">
<header className="page-header">
<button
className="back-button"
onClick={() => navigate('/')}
>
메인으로 돌아가기
</button>
<h1>프로젝트 기본정보 등록</h1>
<p>새로운 프로젝트의 Job No. 기본 정보를 입력해주세요</p>
</header>
<form className="registration-form" onSubmit={handleSubmit}>
<div className="form-grid">
<div className="form-group">
<label htmlFor="jobNo" className="required">Job No.</label>
<input
type="text"
id="jobNo"
name="jobNo"
value={formData.jobNo}
onChange={handleInputChange}
placeholder="예: TK-2025-001"
className={errors.jobNo ? 'error' : ''}
/>
{errors.jobNo && <span className="error-message">{errors.jobNo}</span>}
</div>
<div className="form-group">
<label htmlFor="projectName" className="required">프로젝트명</label>
<input
type="text"
id="projectName"
name="projectName"
value={formData.projectName}
onChange={handleInputChange}
placeholder="프로젝트명을 입력하세요"
className={errors.projectName ? 'error' : ''}
/>
{errors.projectName && <span className="error-message">{errors.projectName}</span>}
</div>
<div className="form-group">
<label htmlFor="clientName" className="required">고객사명</label>
<input
type="text"
id="clientName"
name="clientName"
value={formData.clientName}
onChange={handleInputChange}
placeholder="고객사명을 입력하세요"
className={errors.clientName ? 'error' : ''}
/>
{errors.clientName && <span className="error-message">{errors.clientName}</span>}
</div>
<div className="form-group">
<label htmlFor="location">프로젝트 위치</label>
<input
type="text"
id="location"
name="location"
value={formData.location}
onChange={handleInputChange}
placeholder="예: 울산광역시 남구"
/>
</div>
<div className="form-group">
<label htmlFor="projectType">프로젝트 유형</label>
<div className="project-type-container">
<select
id="projectType"
name="projectType"
value={formData.projectType}
onChange={handleInputChange}
>
{projectTypes.map(type => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
<div className="project-type-actions">
<button
type="button"
className="add-type-btn"
onClick={() => setShowAddProjectType(true)}
title="프로젝트 유형 추가"
>
+
</button>
{projectTypes.length > 1 && (
<button
type="button"
className="remove-type-btn"
onClick={() => removeProjectType(formData.projectType)}
title="현재 선택된 유형 삭제"
>
-
</button>
)}
</div>
</div>
{showAddProjectType && (
<div className="add-project-type-form">
<input
type="text"
value={newProjectType}
onChange={(e) => setNewProjectType(e.target.value)}
placeholder="새 프로젝트 유형 입력"
onKeyPress={(e) => e.key === 'Enter' && addProjectType()}
/>
<button type="button" onClick={addProjectType}>추가</button>
<button type="button" onClick={() => setShowAddProjectType(false)}>취소</button>
</div>
)}
</div>
<div className="form-group">
<label htmlFor="status">프로젝트 상태</label>
<select
id="status"
name="status"
value={formData.status}
onChange={handleInputChange}
>
{statusOptions.map(status => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="contractDate">수주일</label>
<input
type="date"
id="contractDate"
name="contractDate"
value={formData.contractDate}
onChange={handleInputChange}
/>
</div>
<div className="form-group">
<label htmlFor="deliveryDate">납기일</label>
<input
type="date"
id="deliveryDate"
name="deliveryDate"
value={formData.deliveryDate}
onChange={handleInputChange}
className={errors.deliveryDate ? 'error' : ''}
/>
{errors.deliveryDate && <span className="error-message">{errors.deliveryDate}</span>}
</div>
<div className="form-group">
<label htmlFor="deliveryMethod">납품 방법</label>
<select
id="deliveryMethod"
name="deliveryMethod"
value={formData.deliveryMethod}
onChange={handleInputChange}
>
<option value="">납품 방법 선택</option>
<option value="FOB">FOB (Free On Board)</option>
<option value="CIF">CIF (Cost, Insurance and Freight)</option>
<option value="EXW">EXW (Ex Works)</option>
<option value="DDP">DDP (Delivered Duty Paid)</option>
<option value="직접납품">직접납품</option>
<option value="택배">택배</option>
<option value="기타">기타</option>
</select>
</div>
<div className="form-group full-width">
<label htmlFor="description">프로젝트 설명</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleInputChange}
placeholder="프로젝트에 대한 상세 설명을 입력하세요"
rows="4"
/>
</div>
</div>
<div className="form-actions">
<button
type="button"
className="cancel-button"
onClick={() => navigate('/')}
>
취소
</button>
<button
type="submit"
className="submit-button"
disabled={loading}
>
{loading ? '등록 중...' : '프로젝트 등록'}
</button>
</div>
</form>
</div>
</div>
);
};
export default JobRegistrationPage;

View File

@@ -0,0 +1,279 @@
import React, { useEffect, useState } from 'react';
import { fetchJobs } from '../api';
import api from '../api';
const JobSelectionPage = ({ onJobSelect }) => {
const [jobs, setJobs] = useState([]);
const [selectedJobNo, setSelectedJobNo] = useState('');
const [selectedJobName, setSelectedJobName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [editingJobNo, setEditingJobNo] = useState(null);
const [editedName, setEditedName] = useState('');
// 프로젝트 이름 수정
const updateJobName = async (jobNo) => {
try {
const project = jobs.find(j => j.job_no === jobNo);
if (!project) return;
const response = await api.patch(`/dashboard/projects/${project.id}?job_name=${encodeURIComponent(editedName)}`);
if (response.data.success) {
// 로컬 상태 업데이트
setJobs(jobs.map(j =>
j.job_no === jobNo ? { ...j, job_name: editedName } : j
));
// 선택된 프로젝트 이름도 업데이트
if (selectedJobNo === jobNo) {
setSelectedJobName(editedName);
}
setEditingJobNo(null);
setEditedName('');
}
} catch (error) {
console.error('프로젝트 이름 수정 실패:', error);
alert('프로젝트 이름 수정에 실패했습니다.');
}
};
useEffect(() => {
async function loadJobs() {
setLoading(true);
setError('');
try {
const res = await fetchJobs({});
if (res.data && Array.isArray(res.data.jobs)) {
setJobs(res.data.jobs);
} else {
setJobs([]);
}
} catch (e) {
setError('프로젝트 목록을 불러오지 못했습니다.');
} finally {
setLoading(false);
}
}
loadJobs();
}, []);
const handleSelect = (e) => {
const jobNo = e.target.value;
setSelectedJobNo(jobNo);
const job = jobs.find(j => j.job_no === jobNo);
setSelectedJobName(job ? job.job_name : '');
};
const handleConfirm = () => {
if (selectedJobNo && selectedJobName && onJobSelect) {
onJobSelect(selectedJobNo, selectedJobName);
}
};
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '600px', margin: '0 auto' }}>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
📋 프로젝트 선택
</h1>
<p style={{
color: '#718096',
fontSize: '16px',
margin: '0 0 32px 0'
}}>
BOM 관리할 프로젝트를 선택하세요.
</p>
{loading && (
<div style={{ textAlign: 'center', padding: '40px' }}>
로딩 ...
</div>
)}
{error && (
<div style={{
background: '#fed7d7',
border: '1px solid #fc8181',
borderRadius: '8px',
padding: '12px 16px',
marginBottom: '20px',
color: '#c53030'
}}>
{error}
</div>
)}
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
padding: '24px'
}}>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '8px'
}}>
프로젝트 선택
</label>
<select
value={selectedJobNo}
onChange={handleSelect}
style={{
width: '100%',
padding: '12px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
fontSize: '14px',
background: 'white',
marginBottom: '16px'
}}
>
<option value="">프로젝트를 선택하세요</option>
{jobs.map(job => (
<option key={job.job_no} value={job.job_no}>
{job.job_no} - {job.job_name}
</option>
))}
</select>
{selectedJobNo && selectedJobName && (
<div style={{
background: '#c6f6d5',
border: '1px solid #68d391',
borderRadius: '8px',
padding: '12px 16px',
marginBottom: '20px',
color: '#2f855a',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div>
선택된 프로젝트: <strong>{selectedJobNo} - {selectedJobName}</strong>
</div>
<button
onClick={() => {
setEditingJobNo(selectedJobNo);
setEditedName(selectedJobName);
}}
style={{
padding: '6px 12px',
background: '#48bb78',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '600'
}}
title="프로젝트 이름 수정"
>
수정
</button>
</div>
)}
{editingJobNo && (
<div style={{
background: '#fff5f5',
border: '2px solid #fc8181',
borderRadius: '8px',
padding: '16px',
marginBottom: '20px'
}}>
<div style={{ marginBottom: '12px', fontWeight: '600', color: '#742a2a' }}>
프로젝트 이름 수정
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') updateJobName(editingJobNo);
}}
placeholder="새 프로젝트 이름"
autoFocus
style={{
flex: 1,
padding: '10px',
border: '2px solid #3b82f6',
borderRadius: '6px',
fontSize: '14px'
}}
/>
<button
onClick={() => updateJobName(editingJobNo)}
style={{
padding: '10px 16px',
background: '#10b981',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
저장
</button>
<button
onClick={() => {
setEditingJobNo(null);
setEditedName('');
}}
style={{
padding: '10px 16px',
background: '#ef4444',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
취소
</button>
</div>
</div>
)}
<button
onClick={handleConfirm}
disabled={!selectedJobNo}
style={{
width: '100%',
padding: '12px 24px',
background: selectedJobNo ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : '#e2e8f0',
color: selectedJobNo ? 'white' : '#a0aec0',
border: 'none',
borderRadius: '8px',
cursor: selectedJobNo ? 'pointer' : 'not-allowed',
fontSize: '16px',
fontWeight: '600'
}}
>
확인
</button>
</div>
</div>
</div>
);
};
export default JobSelectionPage;

View File

@@ -0,0 +1,528 @@
import React, { useState, useEffect } from 'react';
import api from '../api';
import { reportError, logUserActionError } from '../utils/errorLogger';
const LogMonitoringPage = ({ onNavigate, user }) => {
const [activeTab, setActiveTab] = useState('login-logs'); // 'login-logs', 'activity-logs', 'system-logs'
const [stats, setStats] = useState({
totalUsers: 0,
activeUsers: 0,
todayLogins: 0,
failedLogins: 0,
recentErrors: 0
});
const [recentActivity, setRecentActivity] = useState([]);
const [activityLogs, setActivityLogs] = useState([]);
const [frontendErrors, setFrontendErrors] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [message, setMessage] = useState({ type: '', text: '' });
useEffect(() => {
loadDashboardData();
// 30초마다 자동 새로고침
const interval = setInterval(loadDashboardData, 30000);
return () => clearInterval(interval);
}, []);
const loadActivityLogs = async () => {
try {
const response = await api.get('/auth/logs/system?limit=50');
if (response.data.success) {
setActivityLogs(response.data.logs);
}
} catch (error) {
console.error('활동 로그 로딩 실패:', error);
}
};
const loadDashboardData = async () => {
try {
setIsLoading(true);
// 활동 로그도 함께 로드
if (activeTab === 'activity-logs') {
await loadActivityLogs();
}
// 병렬로 데이터 로드
const [usersResponse, loginLogsResponse] = await Promise.all([
api.get('/auth/users'),
api.get('/auth/logs/login', { params: { limit: 20 } })
]);
// 사용자 통계
if (usersResponse.data.success) {
const users = usersResponse.data.users;
setStats(prev => ({
...prev,
totalUsers: users.length,
activeUsers: users.filter(u => u.is_active).length
}));
}
// 로그인 로그 통계
if (loginLogsResponse.data.success) {
const logs = loginLogsResponse.data.logs;
const today = new Date().toDateString();
const todayLogins = logs.filter(log =>
new Date(log.login_time).toDateString() === today &&
log.login_status === 'success'
).length;
const failedLogins = logs.filter(log =>
new Date(log.login_time).toDateString() === today &&
log.login_status === 'failed'
).length;
setStats(prev => ({
...prev,
todayLogins,
failedLogins
}));
setRecentActivity(logs.slice(0, 10));
}
// 프론트엔드 오류 로그 (로컬 스토리지에서)
const localErrors = JSON.parse(localStorage.getItem('frontend_errors') || '[]');
const recentErrors = localErrors.filter(error => {
const errorDate = new Date(error.timestamp);
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
return errorDate > oneDayAgo;
});
setFrontendErrors(recentErrors.slice(0, 10));
setStats(prev => ({
...prev,
recentErrors: recentErrors.length
}));
} catch (err) {
console.error('Load dashboard data error:', err);
setMessage({ type: 'error', text: '대시보드 데이터 로드 중 오류가 발생했습니다' });
logUserActionError('load_dashboard_data', err, { userId: user?.user_id });
} finally {
setIsLoading(false);
}
};
const clearFrontendErrors = () => {
localStorage.removeItem('frontend_errors');
setFrontendErrors([]);
setStats(prev => ({ ...prev, recentErrors: 0 }));
setMessage({ type: 'success', text: '프론트엔드 오류 로그가 삭제되었습니다' });
};
const formatDateTime = (dateString) => {
try {
return new Date(dateString).toLocaleString('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return dateString;
}
};
const getActivityIcon = (status) => {
return status === 'success' ? '✅' : '❌';
};
const getErrorTypeIcon = (type) => {
const icons = {
'javascript_error': '❌',
'api_error': '🌐',
'user_action_error': '👤',
'promise_rejection': '⚠️',
'react_error_boundary': '⚛️'
};
return icons[type] || '❓';
};
return (
<div style={{ minHeight: '100vh', background: '#f8f9fa' }}>
{/* 헤더 */}
<div style={{
background: 'white',
borderBottom: '1px solid #e9ecef',
padding: '16px 32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<button
onClick={() => onNavigate('dashboard')}
style={{
background: 'none',
border: 'none',
color: '#28a745',
fontSize: '20px',
cursor: 'pointer',
padding: '4px',
borderRadius: '4px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
onMouseLeave={(e) => e.target.style.background = 'none'}
title="대시보드로 돌아가기"
>
</button>
<div>
<h1 style={{ fontSize: '24px', fontWeight: '700', color: '#2d3748', margin: 0 }}>
📈 로그 모니터링
</h1>
</div>
</div>
{/* 탭 네비게이션 */}
<div style={{ background: 'white', borderBottom: '1px solid #e9ecef', padding: '0 32px' }}>
<div style={{ display: 'flex', gap: '0' }}>
<button
onClick={() => setActiveTab('login-logs')}
style={{
padding: '12px 24px',
border: 'none',
background: activeTab === 'login-logs' ? '#4299e1' : 'transparent',
color: activeTab === 'login-logs' ? 'white' : '#4a5568',
borderRadius: '8px 8px 0 0',
cursor: 'pointer',
fontWeight: '600',
fontSize: '14px',
borderBottom: activeTab === 'login-logs' ? '2px solid #4299e1' : '2px solid transparent'
}}
>
🔐 로그인 로그
</button>
<button
onClick={() => setActiveTab('activity-logs')}
style={{
padding: '12px 24px',
border: 'none',
background: activeTab === 'activity-logs' ? '#4299e1' : 'transparent',
color: activeTab === 'activity-logs' ? 'white' : '#4a5568',
borderRadius: '8px 8px 0 0',
cursor: 'pointer',
fontWeight: '600',
fontSize: '14px',
borderBottom: activeTab === 'activity-logs' ? '2px solid #4299e1' : '2px solid transparent'
}}
>
📊 활동 로그
</button>
<button
onClick={() => setActiveTab('system-logs')}
style={{
padding: '12px 24px',
border: 'none',
background: activeTab === 'system-logs' ? '#4299e1' : 'transparent',
color: activeTab === 'system-logs' ? 'white' : '#4a5568',
borderRadius: '8px 8px 0 0',
cursor: 'pointer',
fontWeight: '600',
fontSize: '14px',
borderBottom: activeTab === 'system-logs' ? '2px solid #4299e1' : '2px solid transparent'
}}
>
🖥 시스템 로그
</button>
</div>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={loadDashboardData}
disabled={isLoading}
style={{
background: '#007bff',
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '8px 16px',
fontSize: '14px',
fontWeight: '600',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.6 : 1
}}
>
🔄 새로고침
</button>
{frontendErrors.length > 0 && (
<button
onClick={clearFrontendErrors}
style={{
background: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '8px 16px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer'
}}
>
🗑 오류 로그 삭제
</button>
)}
</div>
</div>
<div style={{ padding: '32px', maxWidth: '1400px', margin: '0 auto' }}>
{/* 메시지 표시 */}
{message.text && (
<div style={{
padding: '12px 16px',
borderRadius: '8px',
marginBottom: '24px',
backgroundColor: message.type === 'success' ? '#d1edff' : '#f8d7da',
border: `1px solid ${message.type === 'success' ? '#bee5eb' : '#f5c6cb'}`,
color: message.type === 'success' ? '#0c5460' : '#721c24'
}}>
{message.text}
</div>
)}
{/* 통계 카드 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: '20px',
marginBottom: '32px'
}}>
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
border: '1px solid #e9ecef'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<div style={{ fontSize: '24px' }}>👥</div>
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
전체 사용자
</h3>
</div>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#2d3748' }}>
{stats.totalUsers}
</div>
<div style={{ fontSize: '14px', color: '#28a745' }}>
활성: {stats.activeUsers}
</div>
</div>
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
border: '1px solid #e9ecef'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<div style={{ fontSize: '24px' }}></div>
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
오늘 로그인
</h3>
</div>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#28a745' }}>
{stats.todayLogins}
</div>
<div style={{ fontSize: '14px', color: '#6c757d' }}>
성공한 로그인
</div>
</div>
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
border: '1px solid #e9ecef'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<div style={{ fontSize: '24px' }}></div>
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
로그인 실패
</h3>
</div>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#dc3545' }}>
{stats.failedLogins}
</div>
<div style={{ fontSize: '14px', color: '#6c757d' }}>
오늘 실패 횟수
</div>
</div>
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
border: '1px solid #e9ecef'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<div style={{ fontSize: '24px' }}></div>
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
최근 오류
</h3>
</div>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#ffc107' }}>
{stats.recentErrors}
</div>
<div style={{ fontSize: '14px', color: '#6c757d' }}>
24시간
</div>
</div>
</div>
{/* 콘텐츠 그리드 */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '24px'
}}>
{/* 최근 활동 */}
<div style={{
background: 'white',
borderRadius: '12px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
overflow: 'hidden'
}}>
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e9ecef',
background: '#f8f9fa'
}}>
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
🔐 최근 로그인 활동
</h2>
</div>
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
{isLoading ? (
<div style={{ padding: '40px', textAlign: 'center' }}>
<div style={{ fontSize: '16px', color: '#6c757d' }}>로딩 ...</div>
</div>
) : recentActivity.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center' }}>
<div style={{ fontSize: '16px', color: '#6c757d' }}>최근 활동이 없습니다</div>
</div>
) : (
recentActivity.map((activity, index) => (
<div key={index} style={{
padding: '16px 24px',
borderBottom: '1px solid #f1f3f4',
display: 'flex',
alignItems: 'center',
gap: '12px'
}}>
<div style={{ fontSize: '20px' }}>
{getActivityIcon(activity.login_status)}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
{activity.name}
</div>
<div style={{ fontSize: '12px', color: '#6c757d' }}>
@{activity.username} {activity.ip_address}
</div>
{activity.failure_reason && (
<div style={{ fontSize: '12px', color: '#dc3545', marginTop: '2px' }}>
{activity.failure_reason}
</div>
)}
</div>
<div style={{ fontSize: '12px', color: '#6c757d' }}>
{formatDateTime(activity.login_time)}
</div>
</div>
))
)}
</div>
</div>
{/* 프론트엔드 오류 */}
<div style={{
background: 'white',
borderRadius: '12px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
overflow: 'hidden'
}}>
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e9ecef',
background: '#f8f9fa'
}}>
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
프론트엔드 오류
</h2>
</div>
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
{frontendErrors.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center' }}>
<div style={{ fontSize: '16px', color: '#6c757d' }}>최근 오류가 없습니다</div>
</div>
) : (
frontendErrors.map((error, index) => (
<div key={index} style={{
padding: '16px 24px',
borderBottom: '1px solid #f1f3f4',
display: 'flex',
alignItems: 'flex-start',
gap: '12px'
}}>
<div style={{ fontSize: '16px', marginTop: '2px' }}>
{getErrorTypeIcon(error.type)}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '14px', fontWeight: '600', color: '#dc3545' }}>
{error.type?.replace('_', ' ').toUpperCase() || 'ERROR'}
</div>
<div style={{
fontSize: '13px',
color: '#495057',
marginTop: '4px',
wordBreak: 'break-word',
lineHeight: '1.4'
}}>
{error.message?.substring(0, 100)}
{error.message?.length > 100 && '...'}
</div>
<div style={{ fontSize: '12px', color: '#6c757d', marginTop: '4px' }}>
{error.url && (
<span>{new URL(error.url).pathname} </span>
)}
{formatDateTime(error.timestamp)}
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
{/* 자동 새로고침 안내 */}
<div style={{
marginTop: '24px',
padding: '16px',
backgroundColor: '#e3f2fd',
border: '1px solid #bbdefb',
borderRadius: '8px',
textAlign: 'center'
}}>
<p style={{ fontSize: '14px', color: '#1565c0', margin: 0 }}>
📊 페이지는 30초마다 자동으로 새로고침됩니다
</p>
</div>
</div>
</div>
);
};
export default LogMonitoringPage;

View File

@@ -0,0 +1,215 @@
.main-page {
min-height: 100vh;
background: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.main-container {
max-width: 1200px;
width: 100%;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.main-header {
text-align: center;
padding: 60px 40px;
background: white;
border-bottom: 1px solid #e2e8f0;
}
.main-header h1 {
font-size: 2.25rem;
color: #1a202c;
margin: 0 0 12px 0;
font-weight: 600;
letter-spacing: -0.025em;
}
.main-header p {
font-size: 1rem;
color: #64748b;
margin: 0;
font-weight: 400;
}
.main-content {
padding: 48px;
}
.banner-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 60px;
}
.main-banner {
background: #ffffff;
border-radius: 8px;
padding: 32px;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid #e2e8f0;
display: flex;
align-items: flex-start;
gap: 24px;
min-height: 160px;
}
.main-banner:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08);
border-color: #cbd5e0;
}
.job-registration-banner:hover {
border-color: #10b981;
}
.bom-management-banner:hover {
border-color: #3b82f6;
}
.banner-icon {
flex-shrink: 0;
width: 64px;
height: 64px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.5rem;
margin-top: 4px;
}
.job-registration-banner .banner-icon {
background: #10b981;
}
.bom-management-banner .banner-icon {
background: #3b82f6;
}
.banner-content {
flex: 1;
}
.banner-content h2 {
font-size: 1.25rem;
color: #1a202c;
margin: 0 0 8px 0;
font-weight: 600;
letter-spacing: -0.025em;
}
.banner-content p {
color: #64748b;
font-size: 0.9rem;
line-height: 1.5;
margin: 0 0 16px 0;
}
.banner-action {
color: #475569;
font-weight: 500;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 4px;
}
.job-registration-banner .banner-action {
color: #10b981;
}
.bom-management-banner .banner-action {
color: #3b82f6;
}
.feature-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
margin-top: 48px;
padding-top: 48px;
border-top: 1px solid #f1f5f9;
}
.feature-item {
text-align: left;
padding: 24px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.feature-item h3 {
font-size: 1rem;
color: #1a202c;
margin: 0 0 8px 0;
font-weight: 600;
}
.feature-item p {
color: #64748b;
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
.main-footer {
text-align: center;
padding: 24px;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
}
.main-footer p {
color: #64748b;
font-size: 0.875rem;
margin: 0;
font-weight: 400;
}
/* 모바일 반응형 */
@media (max-width: 768px) {
.main-page {
padding: 10px;
}
.main-header h1 {
font-size: 2rem;
}
.banner-container {
grid-template-columns: 1fr;
gap: 20px;
}
.main-banner {
flex-direction: column;
text-align: center;
padding: 25px 20px;
min-height: auto;
}
.banner-icon {
margin-bottom: 10px;
}
.feature-info {
grid-template-columns: 1fr;
gap: 20px;
}
.main-content {
padding: 25px 20px;
}
}

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import './MainPage.css';
const MainPage = () => {
const navigate = useNavigate();
return (
<div className="main-page">
<div className="main-container">
<header className="main-header">
<h1>TK Material Planning System</h1>
<p>자재 계획 BOM 관리 시스템</p>
</header>
<div className="main-content">
<div className="banner-container">
<div
className="main-banner job-registration-banner"
onClick={() => navigate('/job-registration')}
>
<div className="banner-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14,2 14,8 20,8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
</div>
<div className="banner-content">
<h2>기본정보 등록</h2>
<p>새로운 프로젝트의 Job No. 기본 정보를 등록합니다</p>
<div className="banner-action">등록하기 </div>
</div>
</div>
<div
className="main-banner bom-management-banner"
onClick={() => navigate('/project-selection')}
>
<div className="banner-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="16" rx="2"/>
<path d="M7 2v4"/>
<path d="M17 2v4"/>
<path d="M14 12h.01"/>
<path d="M10 12h.01"/>
<path d="M16 16h.01"/>
<path d="M12 16h.01"/>
<path d="M8 16h.01"/>
</svg>
</div>
<div className="banner-content">
<h2>BOM 관리</h2>
<p>기존 프로젝트의 BOM 자료를 관리하고 분석합니다</p>
<div className="banner-action">관리하기 </div>
</div>
</div>
</div>
<div className="feature-info">
<div className="feature-item">
<h3>📊 자재 분석</h3>
<p>엑셀 파일 업로드를 통한 자동 자재 분류 분석</p>
</div>
<div className="feature-item">
<h3>💰 구매 최적화</h3>
<p>리비전별 자재 비교 구매 확정 관리</p>
</div>
<div className="feature-item">
<h3>🔧 Tubing 관리</h3>
<p>제조사별 튜빙 규격 품목번호 통합 관리</p>
</div>
</div>
</div>
<footer className="main-footer">
<p>&copy; 2025 Technical Korea. All rights reserved.</p>
</footer>
</div>
</div>
);
};
export default MainPage;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,358 @@
import React, { useState, useEffect } from 'react';
import { api } from '../api';
const ProjectWorkspacePage = ({ project, user, onNavigate, onBackToDashboard }) => {
const [projectStats, setProjectStats] = useState(null);
const [recentFiles, setRecentFiles] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (project) {
loadProjectData();
}
}, [project]);
const loadProjectData = async () => {
try {
// 실제 파일 데이터만 로드
const filesResponse = await api.get(`/files?job_no=${project.job_no}&limit=5`);
if (filesResponse.data && Array.isArray(filesResponse.data)) {
setRecentFiles(filesResponse.data);
// 파일 데이터를 기반으로 통계 계산
const stats = {
totalFiles: filesResponse.data.length,
totalMaterials: filesResponse.data.reduce((sum, file) => sum + (file.parsed_count || 0), 0),
classifiedMaterials: 0, // API에서 분류 정보를 가져와야 함
pendingVerification: 0, // API에서 검증 정보를 가져와야 함
};
setProjectStats(stats);
} else {
setRecentFiles([]);
setProjectStats({
totalFiles: 0,
totalMaterials: 0,
classifiedMaterials: 0,
pendingVerification: 0
});
}
} catch (error) {
console.error('프로젝트 데이터 로딩 실패:', error);
setRecentFiles([]);
setProjectStats({
totalFiles: 0,
totalMaterials: 0,
classifiedMaterials: 0,
pendingVerification: 0
});
} finally {
setLoading(false);
}
};
const getAvailableActions = () => {
const userRole = user?.role || 'user';
const allActions = {
// BOM 관리 (통합)
'bom-management': {
title: 'BOM 관리',
description: 'BOM 파일 업로드, 관리 및 리비전 추적을 수행합니다',
icon: '📋',
color: '#667eea',
roles: ['designer', 'manager', 'admin'],
path: 'bom-status'
},
// 자재 관리
'material-management': {
title: '자재 관리',
description: '자재 분류, 검증 및 구매 관리를 수행합니다',
icon: '🔧',
color: '#48bb78',
roles: ['designer', 'purchaser', 'manager', 'admin'],
path: 'materials'
}
};
// 사용자 권한에 따라 필터링
return Object.entries(allActions).filter(([key, action]) =>
action.roles.includes(userRole)
);
};
const handleActionClick = (actionPath) => {
switch (actionPath) {
case 'bom-management':
onNavigate('bom-status', {
job_no: project.job_no,
job_name: project.project_name
});
break;
case 'material-management':
onNavigate('materials', {
job_no: project.job_no,
job_name: project.project_name
});
break;
default:
alert(`${actionPath} 기능은 곧 구현될 예정입니다.`);
}
};
if (loading) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '200px'
}}>
<div>프로젝트 데이터를 불러오는 ...</div>
</div>
);
}
const availableActions = getAvailableActions();
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '32px'
}}>
<button
onClick={onBackToDashboard}
style={{
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
marginRight: '16px',
padding: '8px'
}}
>
</button>
<div>
<h1 style={{
margin: '0 0 8px 0',
fontSize: '28px',
fontWeight: 'bold',
color: '#2d3748'
}}>
{project.project_name}
</h1>
<div style={{
fontSize: '16px',
color: '#718096'
}}>
{project.job_no} 진행률: {project.progress || 0}%
</div>
</div>
</div>
{/* 프로젝트 통계 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '20px',
marginBottom: '32px'
}}>
{[
{ label: 'BOM 파일', value: projectStats.totalFiles, icon: '📄', color: '#667eea' },
{ label: '전체 자재', value: projectStats.totalMaterials, icon: '📦', color: '#48bb78' },
{ label: '분류 완료', value: projectStats.classifiedMaterials, icon: '✅', color: '#38b2ac' },
{ label: '검증 대기', value: projectStats.pendingVerification, icon: '⏳', color: '#ed8936' }
].map((stat, index) => (
<div key={index} style={{
background: 'white',
padding: '20px',
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
border: '1px solid #e2e8f0'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div>
<div style={{
fontSize: '14px',
color: '#718096',
marginBottom: '4px'
}}>
{stat.label}
</div>
<div style={{
fontSize: '24px',
fontWeight: 'bold',
color: stat.color
}}>
{stat.value}
</div>
</div>
<div style={{ fontSize: '24px' }}>
{stat.icon}
</div>
</div>
</div>
))}
</div>
{/* 업무 메뉴 */}
<div style={{
background: 'white',
borderRadius: '16px',
padding: '32px',
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
marginBottom: '32px'
}}>
<h2 style={{
margin: '0 0 24px 0',
fontSize: '20px',
fontWeight: 'bold',
color: '#2d3748'
}}>
🚀 사용 가능한 업무
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '20px'
}}>
{availableActions.map(([key, action]) => (
<div
key={key}
onClick={() => handleActionClick(key)}
style={{
padding: '20px',
border: '1px solid #e2e8f0',
borderRadius: '12px',
cursor: 'pointer',
transition: 'all 0.2s ease',
background: 'white'
}}
onMouseEnter={(e) => {
e.target.style.borderColor = action.color;
e.target.style.boxShadow = `0 4px 12px ${action.color}20`;
e.target.style.transform = 'translateY(-2px)';
}}
onMouseLeave={(e) => {
e.target.style.borderColor = '#e2e8f0';
e.target.style.boxShadow = 'none';
e.target.style.transform = 'translateY(0)';
}}
>
<div style={{
display: 'flex',
alignItems: 'flex-start',
gap: '16px'
}}>
<div style={{
fontSize: '32px',
lineHeight: 1
}}>
{action.icon}
</div>
<div style={{ flex: 1 }}>
<h3 style={{
margin: '0 0 8px 0',
fontSize: '16px',
fontWeight: 'bold',
color: action.color
}}>
{action.title}
</h3>
<p style={{
margin: 0,
fontSize: '14px',
color: '#718096',
lineHeight: 1.5
}}>
{action.description}
</p>
</div>
</div>
</div>
))}
</div>
</div>
{/* 최근 활동 (옵션) */}
{recentFiles.length > 0 && (
<div style={{
background: 'white',
borderRadius: '16px',
padding: '32px',
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
}}>
<h2 style={{
margin: '0 0 24px 0',
fontSize: '20px',
fontWeight: 'bold',
color: '#2d3748'
}}>
📁 최근 BOM 파일
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{recentFiles.map((file, index) => (
<div key={index} style={{
padding: '16px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div>
<div style={{
fontSize: '16px',
fontWeight: '500',
color: '#2d3748',
marginBottom: '4px'
}}>
{file.original_filename || file.filename}
</div>
<div style={{
fontSize: '14px',
color: '#718096'
}}>
{file.revision} {file.uploaded_by || '시스템'} {file.parsed_count || 0} 자재
</div>
</div>
<button
onClick={() => handleActionClick('materials')}
style={{
padding: '8px 16px',
backgroundColor: '#667eea',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px'
}}
>
자재 보기
</button>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
};
export default ProjectWorkspacePage;

View File

@@ -0,0 +1,497 @@
import React, { useState, useEffect } from 'react';
const ProjectsPage = ({ user }) => {
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreateForm, setShowCreateForm] = useState(false);
const [editingProject, setEditingProject] = useState(null);
const [editedName, setEditedName] = useState('');
// 프로젝트 이름 편집 시작
const startEditing = (project) => {
setEditingProject(project.id);
setEditedName(project.name);
};
// 프로젝트 이름 저장
const saveProjectName = async (projectId) => {
try {
// TODO: API 호출하여 프로젝트 이름 업데이트
// await api.patch(`/dashboard/projects/${projectId}?job_name=${encodeURIComponent(editedName)}`);
// 임시: 로컬 상태만 업데이트
setProjects(projects.map(p =>
p.id === projectId ? { ...p, name: editedName } : p
));
setEditingProject(null);
setEditedName('');
} catch (error) {
console.error('프로젝트 이름 수정 실패:', error);
alert('프로젝트 이름 수정에 실패했습니다.');
}
};
// 편집 취소
const cancelEditing = () => {
setEditingProject(null);
setEditedName('');
};
useEffect(() => {
// 실제로는 API에서 프로젝트 데이터를 가져올 예정
// 현재는 더미 데이터 사용
setTimeout(() => {
setProjects([
{
id: 1,
name: '냉동기 시스템 개발',
type: '냉동기',
status: '진행중',
startDate: '2024-01-15',
endDate: '2024-06-30',
deliveryMethod: 'FOB',
progress: 65,
manager: '김철수'
},
{
id: 2,
name: 'BOG 처리 시스템',
type: 'BOG',
status: '계획',
startDate: '2024-02-01',
endDate: '2024-08-15',
deliveryMethod: 'CIF',
progress: 15,
manager: '이영희'
},
{
id: 3,
name: '다이아프람 펌프 제작',
type: '다이아프람',
status: '완료',
startDate: '2023-10-01',
endDate: '2024-01-31',
deliveryMethod: 'FOB',
progress: 100,
manager: '박민수'
}
]);
setLoading(false);
}, 1000);
}, []);
const getStatusColor = (status) => {
const colors = {
'계획': '#ed8936',
'진행중': '#48bb78',
'완료': '#38b2ac',
'보류': '#e53e3e'
};
return colors[status] || '#718096';
};
const getProgressColor = (progress) => {
if (progress >= 80) return '#48bb78';
if (progress >= 50) return '#ed8936';
return '#e53e3e';
};
if (loading) {
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px',
fontSize: '16px',
color: '#718096'
}}>
프로젝트 목록을 불러오는 ...
</div>
);
}
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '32px'
}}>
<div>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
📋 프로젝트 관리
</h1>
<p style={{
color: '#718096',
fontSize: '16px',
margin: '0'
}}>
전체 프로젝트를 관리하고 진행 상황을 확인하세요.
</p>
</div>
<button
onClick={() => setShowCreateForm(true)}
style={{
padding: '12px 24px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
gap: '8px',
transition: 'transform 0.2s ease'
}}
onMouseEnter={(e) => e.target.style.transform = 'translateY(-1px)'}
onMouseLeave={(e) => e.target.style.transform = 'translateY(0)'}
>
<span></span>
프로젝트
</button>
</div>
{/* 프로젝트 통계 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '16px',
marginBottom: '32px'
}}>
{[
{ label: '전체', count: projects.length, color: '#667eea' },
{ label: '진행중', count: projects.filter(p => p.status === '진행중').length, color: '#48bb78' },
{ label: '완료', count: projects.filter(p => p.status === '완료').length, color: '#38b2ac' },
{ label: '계획', count: projects.filter(p => p.status === '계획').length, color: '#ed8936' }
].map((stat, index) => (
<div key={index} style={{
background: 'white',
padding: '20px',
borderRadius: '12px',
border: '1px solid #e2e8f0',
textAlign: 'center'
}}>
<div style={{
fontSize: '24px',
fontWeight: '700',
color: stat.color,
marginBottom: '4px'
}}>
{stat.count}
</div>
<div style={{
fontSize: '14px',
color: '#718096'
}}>
{stat.label}
</div>
</div>
))}
</div>
{/* 프로젝트 목록 */}
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
overflow: 'hidden'
}}>
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e2e8f0',
background: '#f7fafc'
}}>
<h3 style={{
margin: '0',
fontSize: '16px',
fontWeight: '600',
color: '#2d3748'
}}>
프로젝트 목록 ({projects.length})
</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{
width: '100%',
borderCollapse: 'collapse'
}}>
<thead>
<tr style={{ background: '#f7fafc' }}>
{['프로젝트명', '유형', '상태', '수주일', '납기일', '납품방법', '진행률', '담당자'].map(header => (
<th key={header} style={{
padding: '12px 16px',
textAlign: 'left',
fontSize: '12px',
fontWeight: '600',
color: '#4a5568',
borderBottom: '1px solid #e2e8f0'
}}>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{projects.map(project => (
<tr key={project.id} style={{
borderBottom: '1px solid #e2e8f0',
transition: 'background 0.2s ease'
}}
onMouseEnter={(e) => e.currentTarget.style.background = '#f7fafc'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}>
<td
style={{ padding: '16px', fontWeight: '600', color: '#2d3748', cursor: 'pointer' }}
onDoubleClick={() => startEditing(project)}
title="더블클릭하여 이름 수정"
>
{editingProject === project.id ? (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type="text"
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') saveProjectName(project.id);
if (e.key === 'Escape') cancelEditing();
}}
autoFocus
style={{
flex: 1,
padding: '6px 10px',
border: '2px solid #3b82f6',
borderRadius: '4px',
fontSize: '14px'
}}
/>
<button
onClick={() => saveProjectName(project.id)}
style={{
padding: '6px 12px',
background: '#10b981',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
</button>
<button
onClick={cancelEditing}
style={{
padding: '6px 12px',
background: '#ef4444',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
</button>
</div>
) : (
<span>{project.name}</span>
)}
</td>
<td style={{ padding: '16px' }}>
<span style={{
padding: '4px 8px',
background: '#edf2f7',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500',
color: '#4a5568'
}}>
{project.type}
</span>
</td>
<td style={{ padding: '16px' }}>
<span style={{
padding: '4px 8px',
background: getStatusColor(project.status) + '20',
color: getStatusColor(project.status),
borderRadius: '4px',
fontSize: '12px',
fontWeight: '600'
}}>
{project.status}
</span>
</td>
<td style={{ padding: '16px', color: '#4a5568' }}>
{project.startDate}
</td>
<td style={{ padding: '16px', color: '#4a5568' }}>
{project.endDate}
</td>
<td style={{ padding: '16px', color: '#4a5568' }}>
{project.deliveryMethod}
</td>
<td style={{ padding: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{
flex: 1,
height: '6px',
background: '#e2e8f0',
borderRadius: '3px',
overflow: 'hidden'
}}>
<div style={{
width: `${project.progress}%`,
height: '100%',
background: getProgressColor(project.progress),
transition: 'width 0.3s ease'
}} />
</div>
<span style={{
fontSize: '12px',
fontWeight: '600',
color: getProgressColor(project.progress),
minWidth: '35px'
}}>
{project.progress}%
</span>
</div>
</td>
<td style={{ padding: '16px', color: '#4a5568' }}>
{project.manager}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 프로젝트가 없을 때 */}
{projects.length === 0 && (
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
padding: '60px 40px',
textAlign: 'center'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📋</div>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
등록된 프로젝트가 없습니다
</h3>
<p style={{
color: '#718096',
margin: '0 0 24px 0'
}}>
번째 프로젝트를 등록해보세요.
</p>
<button
onClick={() => setShowCreateForm(true)}
style={{
padding: '12px 24px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
프로젝트 등록
</button>
</div>
)}
</div>
{/* 프로젝트 생성 폼 모달 (향후 구현) */}
{showCreateForm && (
<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: '32px',
maxWidth: '500px',
width: '90%',
maxHeight: '90vh',
overflow: 'auto'
}}>
<h3 style={{ margin: '0 0 16px 0' }}> 프로젝트 등록</h3>
<p style={{ color: '#718096', margin: '0 0 24px 0' }}>
기능은 구현될 예정입니다.
</p>
<button
onClick={() => setShowCreateForm(false)}
style={{
padding: '8px 16px',
background: '#e2e8f0',
border: 'none',
borderRadius: '6px',
cursor: 'pointer'
}}
>
닫기
</button>
</div>
</div>
)}
</div>
);
};
export default ProjectsPage;

View File

@@ -0,0 +1,388 @@
import React, { useState, useEffect } from 'react';
import api from '../api';
const PurchaseBatchPage = ({ onNavigate, fileId, jobNo }) => {
const [batches, setBatches] = useState([]);
const [selectedBatch, setSelectedBatch] = useState(null);
const [batchMaterials, setBatchMaterials] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [activeTab, setActiveTab] = useState('all'); // all, pending, requested, ordered, received
const [message, setMessage] = useState({ type: '', text: '' });
useEffect(() => {
loadBatches();
}, [fileId, jobNo]);
const loadBatches = async () => {
setIsLoading(true);
try {
const params = {};
if (fileId) params.file_id = fileId;
if (jobNo) params.job_no = jobNo;
const response = await api.get('/export/batches', { params });
setBatches(response.data.batches || []);
} catch (error) {
console.error('Failed to load batches:', error);
setMessage({ type: 'error', text: '배치 목록 로드 실패' });
} finally {
setIsLoading(false);
}
};
const loadBatchMaterials = async (exportId) => {
setIsLoading(true);
try {
const response = await api.get(`/export/batch/${exportId}/materials`);
setBatchMaterials(response.data.materials || []);
} catch (error) {
console.error('Failed to load batch materials:', error);
setMessage({ type: 'error', text: '자재 목록 로드 실패' });
} finally {
setIsLoading(false);
}
};
const handleBatchSelect = (batch) => {
setSelectedBatch(batch);
loadBatchMaterials(batch.export_id);
};
const handleDownloadExcel = async (exportId, batchNo) => {
try {
const response = await api.get(`/export/batch/${exportId}/download`, {
responseType: 'blob'
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `batch_${batchNo}.xlsx`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
setMessage({ type: 'success', text: '엑셀 다운로드 완료' });
} catch (error) {
console.error('Failed to download excel:', error);
setMessage({ type: 'error', text: '엑셀 다운로드 실패' });
}
};
const handleBatchStatusUpdate = async (exportId, newStatus) => {
try {
const prNo = prompt('구매요청 번호 (PR)를 입력하세요:');
const response = await api.patch(`/export/batch/${exportId}/status`, {
status: newStatus,
purchase_request_no: prNo
});
if (response.data.success) {
setMessage({ type: 'success', text: response.data.message });
loadBatches();
if (selectedBatch?.export_id === exportId) {
loadBatchMaterials(exportId);
}
}
} catch (error) {
console.error('Failed to update batch status:', error);
setMessage({ type: 'error', text: '상태 업데이트 실패' });
}
};
const getStatusBadge = (status) => {
const styles = {
pending: { bg: '#FFFFE0', color: '#856404', text: '구매 전' },
requested: { bg: '#FFE4B5', color: '#8B4513', text: '구매신청' },
in_progress: { bg: '#ADD8E6', color: '#00008B', text: '진행중' },
ordered: { bg: '#87CEEB', color: '#4682B4', text: '발주완료' },
received: { bg: '#90EE90', color: '#228B22', text: '입고완료' },
completed: { bg: '#98FB98', color: '#006400', text: '완료' }
};
const style = styles[status] || styles.pending;
return (
<span style={{
background: style.bg,
color: style.color,
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600'
}}>
{style.text}
</span>
);
};
const filteredBatches = activeTab === 'all'
? batches
: batches.filter(b => b.batch_status === activeTab);
return (
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ marginBottom: '24px' }}>
<h1 style={{ fontSize: '24px', fontWeight: '600', marginBottom: '8px' }}>
구매 배치 관리
</h1>
<p style={{ color: '#6c757d' }}>
엑셀로 내보낸 자재들을 배치 단위로 관리합니다
</p>
</div>
{/* 메시지 */}
{message.text && (
<div style={{
padding: '12px',
marginBottom: '16px',
borderRadius: '8px',
background: message.type === 'error' ? '#fee' : '#e6ffe6',
color: message.type === 'error' ? '#dc3545' : '#28a745',
border: `1px solid ${message.type === 'error' ? '#fcc' : '#cfc'}`
}}>
{message.text}
</div>
)}
{/* 탭 네비게이션 */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '20px' }}>
{[
{ key: 'all', label: '전체' },
{ key: 'pending', label: '구매 전' },
{ key: 'requested', label: '구매신청' },
{ key: 'in_progress', label: '진행중' },
{ key: 'completed', label: '완료' }
].map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
padding: '8px 16px',
borderRadius: '20px',
border: activeTab === tab.key ? '2px solid #007bff' : '1px solid #dee2e6',
background: activeTab === tab.key ? '#007bff' : 'white',
color: activeTab === tab.key ? 'white' : '#495057',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
{tab.label}
</button>
))}
</div>
{/* 메인 컨텐츠 */}
<div style={{ display: 'grid', gridTemplateColumns: '400px 1fr', gap: '24px' }}>
{/* 배치 목록 */}
<div style={{
background: 'white',
borderRadius: '12px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
overflow: 'hidden'
}}>
<div style={{
padding: '16px',
borderBottom: '1px solid #e9ecef',
background: '#f8f9fa'
}}>
<h2 style={{ fontSize: '16px', fontWeight: '600', margin: 0 }}>
배치 목록 ({filteredBatches.length})
</h2>
</div>
<div style={{ maxHeight: '600px', overflow: 'auto' }}>
{isLoading ? (
<div style={{ padding: '40px', textAlign: 'center' }}>
로딩중...
</div>
) : filteredBatches.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center', color: '#6c757d' }}>
배치가 없습니다
</div>
) : (
filteredBatches.map(batch => (
<div
key={batch.export_id}
onClick={() => handleBatchSelect(batch)}
style={{
padding: '16px',
borderBottom: '1px solid #f1f3f4',
cursor: 'pointer',
background: selectedBatch?.export_id === batch.export_id ? '#f0f8ff' : 'white',
transition: 'background 0.2s'
}}
onMouseEnter={(e) => {
if (selectedBatch?.export_id !== batch.export_id) {
e.currentTarget.style.background = '#f8f9fa';
}
}}
onMouseLeave={(e) => {
if (selectedBatch?.export_id !== batch.export_id) {
e.currentTarget.style.background = 'white';
}
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span style={{ fontWeight: '600', fontSize: '14px' }}>
{batch.batch_no}
</span>
{getStatusBadge(batch.batch_status)}
</div>
<div style={{ fontSize: '12px', color: '#6c757d', marginBottom: '4px' }}>
{batch.job_no} - {batch.job_name}
</div>
<div style={{ fontSize: '12px', color: '#6c757d', marginBottom: '8px' }}>
{batch.category || '전체'} | {batch.material_count} 자재
</div>
<div style={{ display: 'flex', gap: '8px', fontSize: '11px' }}>
<span style={{ background: '#e9ecef', padding: '2px 6px', borderRadius: '4px' }}>
대기: {batch.status_detail.pending}
</span>
<span style={{ background: '#fff3cd', padding: '2px 6px', borderRadius: '4px' }}>
신청: {batch.status_detail.requested}
</span>
<span style={{ background: '#cce5ff', padding: '2px 6px', borderRadius: '4px' }}>
발주: {batch.status_detail.ordered}
</span>
<span style={{ background: '#d4edda', padding: '2px 6px', borderRadius: '4px' }}>
입고: {batch.status_detail.received}
</span>
</div>
<div style={{ marginTop: '8px', fontSize: '11px', color: '#6c757d' }}>
{new Date(batch.export_date).toLocaleDateString()} | {batch.exported_by}
</div>
</div>
))
)}
</div>
</div>
{/* 선택된 배치 상세 */}
{selectedBatch ? (
<div style={{
background: 'white',
borderRadius: '12px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
overflow: 'hidden'
}}>
<div style={{
padding: '16px',
borderBottom: '1px solid #e9ecef',
background: '#f8f9fa'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ fontSize: '16px', fontWeight: '600', margin: 0 }}>
배치 {selectedBatch.batch_no}
</h2>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => handleDownloadExcel(selectedBatch.export_id, selectedBatch.batch_no)}
style={{
padding: '6px 12px',
background: '#28a745',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer'
}}
>
📥 엑셀 다운로드
</button>
<button
onClick={() => handleBatchStatusUpdate(selectedBatch.export_id, 'requested')}
style={{
padding: '6px 12px',
background: '#ffc107',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer'
}}
>
구매신청
</button>
</div>
</div>
</div>
<div style={{ padding: '16px' }}>
<div style={{ overflow: 'auto', maxHeight: '500px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f8f9fa' }}>
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>No</th>
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>카테고리</th>
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>자재 설명</th>
<th style={{ padding: '8px', textAlign: 'center', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>수량</th>
<th style={{ padding: '8px', textAlign: 'center', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>상태</th>
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>PR번호</th>
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>PO번호</th>
</tr>
</thead>
<tbody>
{batchMaterials.map((material, idx) => (
<tr key={material.exported_material_id} style={{ borderBottom: '1px solid #f1f3f4' }}>
<td style={{ padding: '8px', fontSize: '12px' }}>{idx + 1}</td>
<td style={{ padding: '8px', fontSize: '12px' }}>
<span style={{
background: '#e9ecef',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '11px'
}}>
{material.category}
</span>
</td>
<td style={{ padding: '8px', fontSize: '12px' }}>{material.description}</td>
<td style={{ padding: '8px', fontSize: '12px', textAlign: 'center' }}>
{material.quantity} {material.unit}
</td>
<td style={{ padding: '8px', textAlign: 'center' }}>
{getStatusBadge(material.purchase_status)}
</td>
<td style={{ padding: '8px', fontSize: '12px' }}>
{material.purchase_request_no || '-'}
</td>
<td style={{ padding: '8px', fontSize: '12px' }}>
{material.purchase_order_no || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
) : (
<div style={{
background: 'white',
borderRadius: '12px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px'
}}>
<div style={{ textAlign: 'center', color: '#6c757d' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📦</div>
<div style={{ fontSize: '16px' }}>배치를 선택하세요</div>
</div>
</div>
)}
</div>
</div>
);
};
export default PurchaseBatchPage;

View File

@@ -0,0 +1,211 @@
.purchase-request-page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
}
.back-btn {
background: none;
border: none;
color: #007bff;
cursor: pointer;
font-size: 14px;
padding: 0;
margin-bottom: 16px;
}
.back-btn:hover {
text-decoration: underline;
}
.page-header h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 8px 0;
}
.subtitle {
color: #6c757d;
margin: 0;
}
.main-content {
display: grid;
grid-template-columns: 400px 1fr;
gap: 24px;
}
/* 구매신청 목록 패널 */
.requests-panel {
background: white;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}
.panel-header {
padding: 16px;
border-bottom: 1px solid #e9ecef;
background: #f8f9fa;
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-header h2 {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.requests-list {
max-height: 600px;
overflow-y: auto;
}
.request-card {
padding: 16px;
border-bottom: 1px solid #f1f3f4;
cursor: pointer;
transition: background 0.2s;
}
.request-card:hover {
background: #f8f9fa;
}
.request-card.selected {
background: #e7f3ff;
}
.request-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.request-no {
font-weight: 600;
font-size: 14px;
color: #007bff;
}
.request-date {
font-size: 12px;
color: #6c757d;
}
.request-info {
font-size: 13px;
color: #495057;
margin-bottom: 8px;
}
.material-count {
font-size: 12px;
color: #6c757d;
margin-top: 4px;
}
.request-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.requested-by {
font-size: 11px;
color: #6c757d;
}
.download-btn {
background: #28a745;
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
}
.download-btn:hover {
background: #218838;
}
/* 상세 패널 */
.details-panel {
background: white;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}
.excel-btn {
background: #28a745;
color: white;
border: none;
border-radius: 6px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
}
.excel-btn:hover {
background: #218838;
}
.materials-table {
padding: 16px;
overflow: auto;
}
.materials-table table {
width: 100%;
border-collapse: collapse;
}
.materials-table th {
background: #f8f9fa;
padding: 8px;
text-align: left;
font-size: 12px;
font-weight: 600;
border-bottom: 2px solid #dee2e6;
}
.materials-table td {
padding: 8px;
font-size: 12px;
border-bottom: 1px solid #f1f3f4;
}
.category-badge {
background: #e9ecef;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
color: #6c757d;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.loading {
padding: 40px;
text-align: center;
color: #6c757d;
}

View File

@@ -0,0 +1,419 @@
import React, { useState, useEffect } from 'react';
import api from '../api';
import { exportMaterialsToExcel } from '../utils/excelExport';
import './PurchaseRequestPage.css';
const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) => {
const [requests, setRequests] = useState([]);
const [selectedRequest, setSelectedRequest] = useState(null);
const [requestMaterials, setRequestMaterials] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [editingTitle, setEditingTitle] = useState(null);
const [newTitle, setNewTitle] = useState('');
useEffect(() => {
loadRequests();
}, [fileId, jobNo, selectedProject]);
const loadRequests = async () => {
setIsLoading(true);
try {
const params = {};
// 선택된 프로젝트가 있으면 해당 프로젝트만 조회
if (selectedProject) {
params.job_no = selectedProject.job_no || selectedProject.official_project_code;
} else if (jobNo) {
params.job_no = jobNo;
}
if (fileId) params.file_id = fileId;
console.log('🔍 구매신청 목록 조회:', params);
const response = await api.get('/purchase-request/list', { params });
setRequests(response.data.requests || []);
} catch (error) {
console.error('Failed to load requests:', error);
// API 오류 시 대시보드로 리다이렉트
if (error.response?.status === 500 || error.response?.status === 404) {
alert('구매신청 페이지에 문제가 발생했습니다. 대시보드로 이동합니다.');
onNavigate('dashboard');
}
} finally {
setIsLoading(false);
}
};
const loadRequestMaterials = async (requestId) => {
setIsLoading(true);
try {
const response = await api.get(`/purchase-request/${requestId}/materials`);
// 그룹화된 자재가 있으면 우선 표시, 없으면 개별 자재 표시
if (response.data.grouped_materials && response.data.grouped_materials.length > 0) {
setRequestMaterials(response.data.grouped_materials);
} else {
setRequestMaterials(response.data.materials || []);
}
} catch (error) {
console.error('Failed to load materials:', error);
} finally {
setIsLoading(false);
}
};
const handleRequestSelect = (request) => {
setSelectedRequest(request);
loadRequestMaterials(request.request_id);
};
const handleDownloadExcel = async (requestId, requestNo) => {
try {
console.log('📥 엑셀 다운로드 시작:', requestId, requestNo);
// 서버에서 생성된 엑셀 파일 직접 다운로드 (BOM 페이지와 동일한 파일)
const response = await api.get(`/purchase-request/${requestId}/download-excel`, {
responseType: 'blob' // 파일 다운로드용
});
// 파일 다운로드 처리
const blob = new Blob([response.data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${requestNo}_재다운로드.xlsx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
console.log('✅ 엑셀 파일 다운로드 완료');
} catch (error) {
console.error('❌ 엑셀 다운로드 실패:', error);
alert('엑셀 다운로드 실패: ' + error.message);
}
};
const handleEditTitle = (request) => {
setEditingTitle(request.request_id);
setNewTitle(request.request_no);
};
const handleSaveTitle = async (requestId) => {
try {
const response = await api.patch(`/purchase-request/${requestId}/title`, {
title: newTitle
});
if (response.data.success) {
// 목록에서 해당 요청의 제목 업데이트
setRequests(prev => prev.map(req =>
req.request_id === requestId
? { ...req, request_no: newTitle }
: req
));
// 선택된 요청도 업데이트
if (selectedRequest?.request_id === requestId) {
setSelectedRequest(prev => ({ ...prev, request_no: newTitle }));
}
setEditingTitle(null);
setNewTitle('');
console.log('✅ 구매신청 제목 업데이트 완료');
}
} catch (error) {
console.error('❌ 제목 업데이트 실패:', error);
alert('제목 업데이트 실패: ' + error.message);
}
};
const handleCancelEdit = () => {
setEditingTitle(null);
setNewTitle('');
};
return (
<div className="purchase-request-page">
<div className="page-header">
<button onClick={() => onNavigate('dashboard')} className="back-btn">
대시보드로 돌아가기
</button>
<h1>구매신청 관리</h1>
<p className="subtitle">구매신청한 자재들을 그룹별로 관리합니다</p>
</div>
<div className="main-content">
{/* 구매신청 목록 */}
<div className="requests-panel">
<div className="panel-header">
<h2>구매신청 목록 ({requests.length})</h2>
</div>
<div className="requests-list">
{isLoading ? (
<div className="loading">로딩중...</div>
) : requests.length === 0 ? (
<div className="empty-state">구매신청이 없습니다</div>
) : (
requests.map(request => (
<div
key={request.request_id}
className={`request-card ${selectedRequest?.request_id === request.request_id ? 'selected' : ''}`}
onClick={() => handleRequestSelect(request)}
>
<div className="request-header">
{editingTitle === request.request_id ? (
<div className="title-edit" onClick={(e) => e.stopPropagation()}>
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSaveTitle(request.request_id);
} else if (e.key === 'Escape') {
handleCancelEdit();
}
}}
style={{
width: '200px',
padding: '4px 8px',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '14px'
}}
autoFocus
/>
<button
onClick={() => handleSaveTitle(request.request_id)}
style={{
marginLeft: '8px',
padding: '4px 8px',
background: '#10b981',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
저장
</button>
<button
onClick={handleCancelEdit}
style={{
marginLeft: '4px',
padding: '4px 8px',
background: '#6b7280',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
취소
</button>
</div>
) : (
<div className="title-display">
<span className="request-no">{request.request_no}</span>
<button
onClick={(e) => {
e.stopPropagation();
handleEditTitle(request);
}}
style={{
marginLeft: '8px',
padding: '2px 6px',
background: 'transparent',
border: '1px solid #d1d5db',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
color: '#6b7280'
}}
>
</button>
</div>
)}
<span className="request-date">
{new Date(request.requested_at).toLocaleDateString()}
</span>
</div>
<div className="request-info">
<div>{request.job_no} - {request.job_name}</div>
<div className="material-count">
{request.category || '전체'} | {request.material_count} 자재
</div>
</div>
<div className="request-footer">
<span className="requested-by">{request.requested_by}</span>
<button
onClick={(e) => {
e.stopPropagation();
handleDownloadExcel(request.request_id, request.request_no);
}}
className="download-btn"
>
📥 엑셀
</button>
</div>
</div>
))
)}
</div>
</div>
{/* 선택된 구매신청 상세 */}
<div className="details-panel">
{selectedRequest ? (
<>
<div className="panel-header">
<h2>{selectedRequest.request_no}</h2>
<button
onClick={() => handleDownloadExcel(selectedRequest.request_id, selectedRequest.request_no)}
className="excel-btn"
>
📥 엑셀 다운로드
</button>
</div>
{/* 원본 파일 정보 */}
<div className="original-file-info" style={{
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: '8px',
padding: '16px',
marginBottom: '20px'
}}>
<h3 style={{
fontSize: '16px',
fontWeight: '600',
color: '#374151',
marginBottom: '12px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
📄 원본 파일 정보
</h3>
<div className="file-details" style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '12px'
}}>
<div className="file-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span className="label" style={{ fontWeight: '500', color: '#6b7280', minWidth: '80px' }}>파일명:</span>
<span className="value" style={{ color: '#1f2937' }}>{selectedRequest.original_filename || 'N/A'}</span>
</div>
<div className="file-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span className="label" style={{ fontWeight: '500', color: '#6b7280', minWidth: '80px' }}>프로젝트:</span>
<span className="value" style={{ color: '#1f2937' }}>{selectedRequest.job_no} - {selectedRequest.job_name}</span>
</div>
<div className="file-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span className="label" style={{ fontWeight: '500', color: '#6b7280', minWidth: '80px' }}>신청일:</span>
<span className="value" style={{ color: '#1f2937' }}>{new Date(selectedRequest.requested_at).toLocaleString()}</span>
</div>
<div className="file-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span className="label" style={{ fontWeight: '500', color: '#6b7280', minWidth: '80px' }}>신청자:</span>
<span className="value" style={{ color: '#1f2937' }}>{selectedRequest.requested_by}</span>
</div>
<div className="file-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span className="label" style={{ fontWeight: '500', color: '#6b7280', minWidth: '80px' }}>자재 수량:</span>
<span className="value" style={{ color: '#1f2937', fontWeight: '600' }}>{selectedRequest.material_count}</span>
</div>
<div className="file-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span className="label" style={{ fontWeight: '500', color: '#6b7280', minWidth: '80px' }}>카테고리:</span>
<span className="value" style={{ color: '#1f2937' }}>{selectedRequest.category || '전체'}</span>
</div>
</div>
</div>
<div className="materials-table">
{/* 업로드 당시 분류된 정보를 그대로 표시 */}
{requestMaterials.length === 0 ? (
<div className="empty-state">자재 정보가 없습니다</div>
) : (
<div>
{/* 카테고리별로 그룹화하여 표시 */}
{(() => {
// 카테고리별로 자재 그룹화
const groupedByCategory = requestMaterials.reduce((acc, material) => {
const category = material.category || material.classified_category || 'UNCLASSIFIED';
if (!acc[category]) acc[category] = [];
acc[category].push(material);
return acc;
}, {});
return Object.entries(groupedByCategory).map(([category, materials]) => (
<div key={category} style={{ marginBottom: '30px' }}>
<h3 style={{
background: '#f0f0f0',
padding: '10px',
borderRadius: '4px',
fontSize: '14px',
fontWeight: 'bold'
}}>
{category} ({materials.length})
</h3>
<table>
<thead>
<tr>
<th>No</th>
<th>카테고리</th>
<th>자재 설명</th>
<th>크기</th>
<th>스케줄</th>
<th>재질</th>
<th>수량</th>
<th>사용자요구</th>
</tr>
</thead>
<tbody>
{materials.map((material, idx) => (
<tr key={material.item_id || material.id || `${category}-${idx}`}>
<td>{idx + 1}</td>
<td>
<span className="category-badge">
{material.category || material.classified_category}
</span>
</td>
<td>{material.description || material.original_description}</td>
<td>{material.size || material.size_spec || '-'}</td>
<td>{material.schedule || '-'}</td>
<td>{material.material_grade || material.full_material_grade || '-'}</td>
<td>
<span style={{ fontWeight: 'bold' }}>
{Math.round(material.quantity || material.requested_quantity || 0)} {material.unit || material.requested_unit || '개'}
</span>
</td>
<td>{material.user_requirement || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
));
})()}
</div>
)}
</div>
</>
) : (
<div className="empty-state">
<div className="empty-icon">📦</div>
<div>구매신청을 선택하세요</div>
</div>
)}
</div>
</div>
</div>
);
};
export default PurchaseRequestPage;

View File

@@ -0,0 +1,439 @@
import React, { useState, useEffect } from 'react';
import api from '../api';
import { reportError, logUserActionError } from '../utils/errorLogger';
const SystemLogsPage = ({ onNavigate, user }) => {
const [activeTab, setActiveTab] = useState('login');
const [loginLogs, setLoginLogs] = useState([]);
const [systemLogs, setSystemLogs] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState({ type: '', text: '' });
// 필터 상태
const [filters, setFilters] = useState({
status: '',
level: '',
userId: '',
limit: 50
});
useEffect(() => {
if (activeTab === 'login') {
loadLoginLogs();
} else {
loadSystemLogs();
}
}, [activeTab, filters]);
const loadLoginLogs = async () => {
try {
setIsLoading(true);
const params = {
limit: filters.limit,
...(filters.status && { status: filters.status }),
...(filters.userId && { user_id: filters.userId })
};
const response = await api.get('/auth/logs/login', { params });
if (response.data.success) {
setLoginLogs(response.data.logs);
} else {
setMessage({ type: 'error', text: '로그인 로그를 불러올 수 없습니다' });
}
} catch (err) {
console.error('Load login logs error:', err);
setMessage({ type: 'error', text: '로그인 로그 조회 중 오류가 발생했습니다' });
logUserActionError('load_login_logs', err, { userId: user?.user_id });
} finally {
setIsLoading(false);
}
};
const loadSystemLogs = async () => {
try {
setIsLoading(true);
const params = {
limit: filters.limit,
...(filters.level && { level: filters.level })
};
const response = await api.get('/auth/logs/system', { params });
if (response.data.success) {
setSystemLogs(response.data.logs);
} else {
setMessage({ type: 'error', text: '시스템 로그를 불러올 수 없습니다' });
}
} catch (err) {
console.error('Load system logs error:', err);
setMessage({ type: 'error', text: '시스템 로그 조회 중 오류가 발생했습니다' });
logUserActionError('load_system_logs', err, { userId: user?.user_id });
} finally {
setIsLoading(false);
}
};
const getStatusBadge = (status) => {
const colors = {
'success': { bg: '#d1edff', color: '#0c5460' },
'failed': { bg: '#f8d7da', color: '#721c24' }
};
const color = colors[status] || colors.failed;
return (
<span style={{
background: color.bg,
color: color.color,
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600'
}}>
{status === 'success' ? '성공' : '실패'}
</span>
);
};
const getLevelBadge = (level) => {
const colors = {
'ERROR': { bg: '#f8d7da', color: '#721c24' },
'WARNING': { bg: '#fff3cd', color: '#856404' },
'INFO': { bg: '#d1ecf1', color: '#0c5460' },
'DEBUG': { bg: '#e2e3e5', color: '#383d41' }
};
const color = colors[level] || colors.INFO;
return (
<span style={{
background: color.bg,
color: color.color,
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600'
}}>
{level}
</span>
);
};
const formatDateTime = (dateString) => {
try {
return new Date(dateString).toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
} catch {
return dateString;
}
};
return (
<div style={{ minHeight: '100vh', background: '#f8f9fa' }}>
{/* 헤더 */}
<div style={{
background: 'white',
borderBottom: '1px solid #e9ecef',
padding: '16px 32px',
display: 'flex',
alignItems: 'center',
gap: '16px'
}}>
<button
onClick={() => onNavigate('dashboard')}
style={{
background: 'none',
border: 'none',
color: '#28a745',
fontSize: '20px',
cursor: 'pointer',
padding: '4px',
borderRadius: '4px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
onMouseLeave={(e) => e.target.style.background = 'none'}
title="대시보드로 돌아가기"
>
</button>
<div>
<h1 style={{ fontSize: '24px', fontWeight: '700', color: '#2d3748', margin: 0 }}>
📊 시스템 로그
</h1>
<p style={{ color: '#6c757d', fontSize: '14px', margin: '4px 0 0 0' }}>
로그인 기록과 시스템 오류 로그를 조회하세요
</p>
</div>
</div>
<div style={{ padding: '32px', maxWidth: '1400px', margin: '0 auto' }}>
{/* 탭 메뉴 */}
<div style={{
display: 'flex',
borderBottom: '2px solid #e9ecef',
marginBottom: '24px'
}}>
<button
onClick={() => setActiveTab('login')}
style={{
padding: '12px 24px',
background: 'none',
border: 'none',
borderBottom: activeTab === 'login' ? '2px solid #007bff' : '2px solid transparent',
color: activeTab === 'login' ? '#007bff' : '#6c757d',
fontSize: '16px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
>
🔐 로그인 로그
</button>
<button
onClick={() => setActiveTab('system')}
style={{
padding: '12px 24px',
background: 'none',
border: 'none',
borderBottom: activeTab === 'system' ? '2px solid #007bff' : '2px solid transparent',
color: activeTab === 'system' ? '#007bff' : '#6c757d',
fontSize: '16px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
>
🖥 시스템 로그
</button>
</div>
{/* 필터 */}
<div style={{
background: 'white',
borderRadius: '8px',
padding: '16px',
marginBottom: '24px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}>
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', flexWrap: 'wrap' }}>
{activeTab === 'login' && (
<div>
<label style={{ fontSize: '14px', fontWeight: '600', color: '#374151', marginRight: '8px' }}>
상태:
</label>
<select
value={filters.status}
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value }))}
style={{
padding: '6px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
>
<option value="">전체</option>
<option value="success">성공</option>
<option value="failed">실패</option>
</select>
</div>
)}
{activeTab === 'system' && (
<div>
<label style={{ fontSize: '14px', fontWeight: '600', color: '#374151', marginRight: '8px' }}>
레벨:
</label>
<select
value={filters.level}
onChange={(e) => setFilters(prev => ({ ...prev, level: e.target.value }))}
style={{
padding: '6px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
>
<option value="">전체</option>
<option value="ERROR">ERROR</option>
<option value="WARNING">WARNING</option>
<option value="INFO">INFO</option>
<option value="DEBUG">DEBUG</option>
</select>
</div>
)}
<div>
<label style={{ fontSize: '14px', fontWeight: '600', color: '#374151', marginRight: '8px' }}>
개수:
</label>
<select
value={filters.limit}
onChange={(e) => setFilters(prev => ({ ...prev, limit: parseInt(e.target.value) }))}
style={{
padding: '6px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</div>
<button
onClick={() => activeTab === 'login' ? loadLoginLogs() : loadSystemLogs()}
style={{
padding: '6px 16px',
background: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer'
}}
>
🔄 새로고침
</button>
</div>
</div>
{/* 메시지 표시 */}
{message.text && (
<div style={{
padding: '12px 16px',
borderRadius: '8px',
marginBottom: '24px',
backgroundColor: message.type === 'success' ? '#d1edff' : '#f8d7da',
border: `1px solid ${message.type === 'success' ? '#bee5eb' : '#f5c6cb'}`,
color: message.type === 'success' ? '#0c5460' : '#721c24'
}}>
{message.text}
</div>
)}
{/* 로그 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
overflow: 'hidden'
}}>
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e9ecef',
background: '#f8f9fa'
}}>
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
{activeTab === 'login' ? '로그인 로그' : '시스템 로그'}
({activeTab === 'login' ? loginLogs.length : systemLogs.length})
</h2>
</div>
{isLoading ? (
<div style={{ padding: '40px', textAlign: 'center' }}>
<div style={{ fontSize: '16px', color: '#6c757d' }}>로딩 ...</div>
</div>
) : (
<div style={{ overflow: 'auto' }}>
{activeTab === 'login' ? (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f8f9fa' }}>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>시간</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>사용자</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>상태</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>IP 주소</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>실패 사유</th>
</tr>
</thead>
<tbody>
{loginLogs.length === 0 ? (
<tr>
<td colSpan={5} style={{ padding: '40px', textAlign: 'center', color: '#6c757d' }}>
로그인 로그가 없습니다
</td>
</tr>
) : (
loginLogs.map((log, index) => (
<tr key={index} style={{ borderBottom: '1px solid #f1f3f4' }}>
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#495057' }}>
{formatDateTime(log.login_time)}
</td>
<td style={{ padding: '12px 16px' }}>
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
{log.name}
</div>
<div style={{ fontSize: '12px', color: '#6c757d' }}>
@{log.username}
</div>
</td>
<td style={{ padding: '12px 16px' }}>
{getStatusBadge(log.login_status)}
</td>
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#495057' }}>
{log.ip_address || '-'}
</td>
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#dc3545' }}>
{log.failure_reason || '-'}
</td>
</tr>
))
)}
</tbody>
</table>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f8f9fa' }}>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>시간</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>레벨</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>모듈</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>메시지</th>
</tr>
</thead>
<tbody>
{systemLogs.length === 0 ? (
<tr>
<td colSpan={4} style={{ padding: '40px', textAlign: 'center', color: '#6c757d' }}>
시스템 로그가 없습니다
</td>
</tr>
) : (
systemLogs.map((log, index) => (
<tr key={index} style={{ borderBottom: '1px solid #f1f3f4' }}>
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#495057' }}>
{formatDateTime(log.timestamp)}
</td>
<td style={{ padding: '12px 16px' }}>
{getLevelBadge(log.level)}
</td>
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#495057' }}>
{log.module || '-'}
</td>
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#495057', maxWidth: '400px', wordBreak: 'break-word' }}>
{log.message}
</td>
</tr>
))
)}
</tbody>
</table>
)}
</div>
)}
</div>
</div>
</div>
);
};
export default SystemLogsPage;

View File

@@ -0,0 +1,664 @@
import React, { useState, useEffect } from 'react';
import api from '../api';
const SystemSettingsPage = ({ onNavigate, user }) => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
const [activeTab, setActiveTab] = useState('users'); // 'users', 'login-logs', 'activity-logs'
const [loginLogs, setLoginLogs] = useState([]);
const [activityLogs, setActivityLogs] = useState([]);
const [newUser, setNewUser] = useState({
username: '',
email: '',
password: '',
full_name: '',
role: 'user'
});
useEffect(() => {
loadUsers();
if (activeTab === 'login-logs') {
loadLoginLogs();
} else if (activeTab === 'activity-logs') {
loadActivityLogs();
}
}, [activeTab]);
const loadUsers = async () => {
try {
setLoading(true);
const response = await api.get('/auth/users');
if (response.data.success) {
setUsers(response.data.users);
}
} catch (err) {
console.error('사용자 목록 로딩 실패:', err);
setError('사용자 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
const loadLoginLogs = async () => {
try {
setLoading(true);
const response = await api.get('/auth/logs/login?limit=50');
if (response.data.success) {
setLoginLogs(response.data.logs);
}
} catch (err) {
console.error('로그인 로그 로딩 실패:', err);
setError('로그인 로그를 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
const loadActivityLogs = async () => {
try {
setLoading(true);
const response = await api.get('/auth/logs/system?limit=50');
if (response.data.success) {
setActivityLogs(response.data.logs);
}
} catch (err) {
console.error('활동 로그 로딩 실패:', err);
setError('활동 로그를 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
const handleCreateUser = async (e) => {
e.preventDefault();
if (!newUser.username || !newUser.email || !newUser.password) {
setError('모든 필수 필드를 입력해주세요.');
return;
}
try {
setLoading(true);
const response = await api.post('/auth/register', newUser);
if (response.data.success) {
alert('사용자가 성공적으로 생성되었습니다.');
setNewUser({
username: '',
email: '',
password: '',
full_name: '',
role: 'user'
});
setShowCreateForm(false);
loadUsers();
}
} catch (err) {
console.error('사용자 생성 실패:', err);
setError(err.response?.data?.detail || '사용자 생성에 실패했습니다.');
} finally {
setLoading(false);
}
};
const handleDeleteUser = async (userId) => {
if (!confirm('정말로 이 사용자를 삭제하시겠습니까?')) {
return;
}
try {
setLoading(true);
const response = await api.delete(`/auth/users/${userId}`);
if (response.data.success) {
alert('사용자가 삭제되었습니다.');
loadUsers();
}
} catch (err) {
console.error('사용자 삭제 실패:', err);
setError('사용자 삭제에 실패했습니다.');
} finally {
setLoading(false);
}
};
const getRoleDisplay = (role) => {
switch (role) {
case 'admin': return '관리자';
case 'manager': return '매니저';
case 'user': return '사용자';
default: return role;
}
};
const getRoleBadgeColor = (role) => {
switch (role) {
case 'admin': return '#dc2626';
case 'manager': return '#ea580c';
case 'user': return '#059669';
default: return '#6b7280';
}
};
const getActivityTypeColor = (activityType) => {
switch (activityType) {
case 'FILE_UPLOAD':
return '#10b981'; // 초록색
case '파일 정보 수정':
return '#f59e0b'; // 주황색
case '엑셀 내보내기':
return '#3b82f6'; // 파란색
case '자재 목록 조회':
return '#8b5cf6'; // 보라색
case 'LOGIN':
return '#6b7280'; // 회색
default:
return '#6b7280';
}
};
// 관리자 권한 확인 (system이 최고 권한)
if (user?.role !== 'admin' && user?.role !== 'system') {
return (
<div style={{ padding: '32px', textAlign: 'center' }}>
<h2 style={{ color: '#dc2626', marginBottom: '16px' }}>접근 권한이 없습니다</h2>
<p style={{ color: '#6b7280', marginBottom: '24px' }}>
시스템 설정은 관리자만 접근할 있습니다.
</p>
<button
onClick={() => onNavigate('dashboard')}
style={{
background: '#4299e1',
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '12px 24px',
cursor: 'pointer'
}}
>
대시보드로 돌아가기
</button>
</div>
);
}
return (
<div style={{ padding: '32px', maxWidth: '1200px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '32px'
}}>
<div>
<h1 style={{ fontSize: '28px', fontWeight: '700', color: '#2d3748', marginBottom: '8px' }}>
시스템 설정
</h1>
<p style={{ color: '#718096', fontSize: '16px' }}>
사용자 계정 관리 시스템 로그 모니터링
</p>
</div>
</div>
{/* 탭 네비게이션 */}
<div style={{ marginBottom: '24px', borderBottom: '2px solid #e2e8f0' }}>
<div style={{ display: 'flex', gap: '0' }}>
<button
onClick={() => setActiveTab('users')}
style={{
padding: '12px 24px',
border: 'none',
background: activeTab === 'users' ? '#4299e1' : 'transparent',
color: activeTab === 'users' ? 'white' : '#4a5568',
borderRadius: '8px 8px 0 0',
cursor: 'pointer',
fontWeight: '600',
fontSize: '14px',
borderBottom: activeTab === 'users' ? '2px solid #4299e1' : '2px solid transparent'
}}
>
👥 사용자 관리
</button>
<button
onClick={() => setActiveTab('login-logs')}
style={{
padding: '12px 24px',
border: 'none',
background: activeTab === 'login-logs' ? '#4299e1' : 'transparent',
color: activeTab === 'login-logs' ? 'white' : '#4a5568',
borderRadius: '8px 8px 0 0',
cursor: 'pointer',
fontWeight: '600',
fontSize: '14px',
borderBottom: activeTab === 'login-logs' ? '2px solid #4299e1' : '2px solid transparent'
}}
>
🔐 로그인 로그
</button>
<button
onClick={() => setActiveTab('activity-logs')}
style={{
padding: '12px 24px',
border: 'none',
background: activeTab === 'activity-logs' ? '#4299e1' : 'transparent',
color: activeTab === 'activity-logs' ? 'white' : '#4a5568',
borderRadius: '8px 8px 0 0',
cursor: 'pointer',
fontWeight: '600',
fontSize: '14px',
borderBottom: activeTab === 'activity-logs' ? '2px solid #4299e1' : '2px solid transparent'
}}
>
📊 활동 로그
</button>
</div>
<button
onClick={() => onNavigate('dashboard')}
style={{
background: '#e2e8f0',
color: '#4a5568',
border: 'none',
borderRadius: '6px',
padding: '12px 20px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
대시보드
</button>
</div>
{error && (
<div style={{
background: '#fed7d7',
color: '#c53030',
padding: '12px 16px',
borderRadius: '6px',
marginBottom: '24px'
}}>
{error}
</div>
)}
{/* 사용자 관리 섹션 */}
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.07)',
border: '1px solid #e2e8f0',
marginBottom: '24px'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px'
}}>
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748' }}>
👥 사용자 관리
</h2>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
style={{
background: '#38a169',
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
+ 사용자 생성
</button>
</div>
{/* 사용자 생성 폼 */}
{showCreateForm && (
<div style={{
background: '#f7fafc',
padding: '20px',
borderRadius: '8px',
marginBottom: '24px',
border: '1px solid #e2e8f0'
}}>
<h3 style={{ fontSize: '16px', fontWeight: '600', marginBottom: '16px' }}>
사용자 생성
</h3>
<form onSubmit={handleCreateUser}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
사용자명 *
</label>
<input
type="text"
value={newUser.username}
onChange={(e) => setNewUser({...newUser, username: e.target.value})}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
required
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
이메일 *
</label>
<input
type="email"
value={newUser.email}
onChange={(e) => setNewUser({...newUser, email: e.target.value})}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
required
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
비밀번호 *
</label>
<input
type="password"
value={newUser.password}
onChange={(e) => setNewUser({...newUser, password: e.target.value})}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
required
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
전체 이름
</label>
<input
type="text"
value={newUser.full_name}
onChange={(e) => setNewUser({...newUser, full_name: e.target.value})}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
/>
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
권한
</label>
<select
value={newUser.role}
onChange={(e) => setNewUser({...newUser, role: e.target.value})}
style={{
width: '200px',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
>
<option value="user">사용자</option>
<option value="manager">매니저</option>
<option value="admin">관리자</option>
</select>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="submit"
disabled={loading}
style={{
background: '#38a169',
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '10px 16px',
cursor: loading ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
{loading ? '생성 중...' : '사용자 생성'}
</button>
<button
type="button"
onClick={() => setShowCreateForm(false)}
style={{
background: '#e2e8f0',
color: '#4a5568',
border: 'none',
borderRadius: '6px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
취소
</button>
</div>
</form>
</div>
)}
{/* 탭별 콘텐츠 */}
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<div style={{ fontSize: '16px', color: '#718096' }}>로딩 ...</div>
</div>
) : activeTab === 'users' ? (
// 사용자 관리 탭
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #e2e8f0' }}>
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
사용자명
</th>
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
이메일
</th>
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
전체 이름
</th>
<th style={{ padding: '12px', textAlign: 'center', fontWeight: '600', color: '#4a5568' }}>
권한
</th>
<th style={{ padding: '12px', textAlign: 'center', fontWeight: '600', color: '#4a5568' }}>
상태
</th>
<th style={{ padding: '12px', textAlign: 'center', fontWeight: '600', color: '#4a5568' }}>
작업
</th>
</tr>
</thead>
<tbody>
{users.map((userItem) => (
<tr key={userItem.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
<td style={{ padding: '12px', fontWeight: '500' }}>
{userItem.username}
</td>
<td style={{ padding: '12px', color: '#4a5568' }}>
{userItem.email}
</td>
<td style={{ padding: '12px', color: '#4a5568' }}>
{userItem.full_name || '-'}
</td>
<td style={{ padding: '12px', textAlign: 'center' }}>
<span style={{
background: getRoleBadgeColor(userItem.role),
color: 'white',
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600'
}}>
{getRoleDisplay(userItem.role)}
</span>
</td>
<td style={{ padding: '12px', textAlign: 'center' }}>
<span style={{
background: userItem.is_active ? '#d1fae5' : '#fee2e2',
color: userItem.is_active ? '#065f46' : '#dc2626',
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600'
}}>
{userItem.is_active ? '활성' : '비활성'}
</span>
</td>
<td style={{ padding: '12px', textAlign: 'center' }}>
{userItem.id !== user?.id && (
<button
onClick={() => handleDeleteUser(userItem.id)}
style={{
background: '#dc2626',
color: 'white',
border: 'none',
borderRadius: '4px',
padding: '6px 12px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '600'
}}
>
삭제
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : activeTab === 'login-logs' ? (
// 로그인 로그 탭
<div style={{ overflowX: 'auto' }}>
<h3 style={{ marginBottom: '16px', color: '#2d3748' }}>🔐 로그인 로그</h3>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #e2e8f0' }}>
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
사용자명
</th>
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
IP 주소
</th>
<th style={{ padding: '12px', textAlign: 'center', fontWeight: '600', color: '#4a5568' }}>
상태
</th>
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
로그인 시간
</th>
</tr>
</thead>
<tbody>
{loginLogs.map((log, index) => (
<tr key={index} style={{ borderBottom: '1px solid #e2e8f0' }}>
<td style={{ padding: '12px', fontWeight: '500' }}>
{log.username}
</td>
<td style={{ padding: '12px', color: '#4a5568' }}>
{log.ip_address}
</td>
<td style={{ padding: '12px', textAlign: 'center' }}>
<span style={{
background: log.status === 'success' ? '#48bb78' : '#f56565',
color: 'white',
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px'
}}>
{log.status === 'success' ? '성공' : '실패'}
</span>
</td>
<td style={{ padding: '12px', color: '#4a5568' }}>
{new Date(log.login_time).toLocaleString('ko-KR')}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : activeTab === 'activity-logs' ? (
// 활동 로그 탭
<div style={{ overflowX: 'auto' }}>
<h3 style={{ marginBottom: '16px', color: '#2d3748' }}>📊 활동 로그</h3>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #e2e8f0' }}>
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
사용자명
</th>
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
활동 유형
</th>
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
상세 내용
</th>
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
시간
</th>
</tr>
</thead>
<tbody>
{activityLogs.map((log, index) => (
<tr key={index} style={{ borderBottom: '1px solid #e2e8f0' }}>
<td style={{ padding: '12px', fontWeight: '500' }}>
{log.username}
</td>
<td style={{ padding: '12px' }}>
<span style={{
background: getActivityTypeColor(log.activity_type),
color: 'white',
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px'
}}>
{log.activity_type}
</span>
</td>
<td style={{ padding: '12px', color: '#4a5568' }}>
{log.activity_description}
</td>
<td style={{ padding: '12px', color: '#4a5568' }}>
{new Date(log.created_at).toLocaleString('ko-KR')}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
</div>
);
};
export default SystemSettingsPage;

View File

@@ -0,0 +1,509 @@
import React, { useState } from 'react';
import api from '../api';
import { reportError } from '../utils/errorLogger';
const SystemSetupPage = ({ onSetupComplete }) => {
const [formData, setFormData] = useState({
username: '',
password: '',
confirmPassword: '',
name: '',
email: '',
department: '',
position: ''
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [validationErrors, setValidationErrors] = useState({});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// 입력 시 해당 필드의 에러 메시지 초기화
if (validationErrors[name]) {
setValidationErrors(prev => ({
...prev,
[name]: ''
}));
}
if (error) setError('');
};
const validateForm = () => {
const errors = {};
// 필수 필드 검증
if (!formData.username.trim()) {
errors.username = '사용자명을 입력해주세요';
} else if (formData.username.length < 3 || formData.username.length > 20) {
errors.username = '사용자명은 3-20자여야 합니다';
} else if (!/^[a-zA-Z0-9_]+$/.test(formData.username)) {
errors.username = '사용자명은 영문, 숫자, 언더스코어만 사용 가능합니다';
}
if (!formData.password) {
errors.password = '비밀번호를 입력해주세요';
} else if (formData.password.length < 8) {
errors.password = '비밀번호는 8자 이상이어야 합니다';
}
if (!formData.confirmPassword) {
errors.confirmPassword = '비밀번호 확인을 입력해주세요';
} else if (formData.password !== formData.confirmPassword) {
errors.confirmPassword = '비밀번호가 일치하지 않습니다';
}
if (!formData.name.trim()) {
errors.name = '이름을 입력해주세요';
} else if (formData.name.length < 2 || formData.name.length > 50) {
errors.name = '이름은 2-50자여야 합니다';
}
// 이메일 검증 (선택사항)
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = '올바른 이메일 형식을 입력해주세요';
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setIsLoading(true);
setError('');
try {
const setupData = {
username: formData.username.trim(),
password: formData.password,
name: formData.name.trim(),
email: formData.email.trim() || null,
department: formData.department.trim() || null,
position: formData.position.trim() || null
};
const response = await api.post('/setup/initialize', setupData);
if (response.data.success) {
// 설정 완료 후 콜백 호출
if (onSetupComplete) {
onSetupComplete(response.data);
}
} else {
setError(response.data.message || '시스템 초기화에 실패했습니다');
}
} catch (err) {
console.error('System setup error:', err);
const errorMessage = err.response?.data?.detail ||
err.response?.data?.message ||
'시스템 초기화 중 오류가 발생했습니다';
setError(errorMessage);
// 오류 로깅
reportError('System setup failed', {
error: err.message,
response: err.response?.data,
formData: { ...formData, password: '[HIDDEN]', confirmPassword: '[HIDDEN]' }
});
} finally {
setIsLoading(false);
}
};
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f8f9fa',
padding: '20px'
}}>
<div style={{
maxWidth: '500px',
width: '100%',
backgroundColor: 'white',
borderRadius: '12px',
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.1)',
padding: '40px'
}}>
{/* 헤더 */}
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🚀</div>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
marginBottom: '8px'
}}>
시스템 초기 설정
</h1>
<p style={{
fontSize: '16px',
color: '#718096',
lineHeight: '1.5'
}}>
TK-MP 시스템을 처음 사용하시는군요!<br />
시스템 관리자 계정을 생성해주세요.
</p>
</div>
{/* 폼 */}
<form onSubmit={handleSubmit}>
{/* 사용자명 */}
<div style={{ marginBottom: '20px' }}>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
marginBottom: '6px'
}}>
사용자명 *
</label>
<input
type="text"
name="username"
value={formData.username}
onChange={handleChange}
placeholder="영문, 숫자, 언더스코어 (3-20자)"
style={{
width: '100%',
padding: '12px',
border: validationErrors.username ? '2px solid #ef4444' : '1px solid #d1d5db',
borderRadius: '8px',
fontSize: '14px',
outline: 'none',
transition: 'border-color 0.2s',
boxSizing: 'border-box'
}}
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
onBlur={(e) => e.target.style.borderColor = validationErrors.username ? '#ef4444' : '#d1d5db'}
/>
{validationErrors.username && (
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
{validationErrors.username}
</p>
)}
</div>
{/* 이름 */}
<div style={{ marginBottom: '20px' }}>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
marginBottom: '6px'
}}>
이름 *
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="실제 이름을 입력해주세요"
style={{
width: '100%',
padding: '12px',
border: validationErrors.name ? '2px solid #ef4444' : '1px solid #d1d5db',
borderRadius: '8px',
fontSize: '14px',
outline: 'none',
transition: 'border-color 0.2s',
boxSizing: 'border-box'
}}
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
onBlur={(e) => e.target.style.borderColor = validationErrors.name ? '#ef4444' : '#d1d5db'}
/>
{validationErrors.name && (
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
{validationErrors.name}
</p>
)}
</div>
{/* 비밀번호 */}
<div style={{ marginBottom: '20px' }}>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
marginBottom: '6px'
}}>
비밀번호 *
</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="8자 이상 입력해주세요"
style={{
width: '100%',
padding: '12px',
border: validationErrors.password ? '2px solid #ef4444' : '1px solid #d1d5db',
borderRadius: '8px',
fontSize: '14px',
outline: 'none',
transition: 'border-color 0.2s',
boxSizing: 'border-box'
}}
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
onBlur={(e) => e.target.style.borderColor = validationErrors.password ? '#ef4444' : '#d1d5db'}
/>
{validationErrors.password && (
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
{validationErrors.password}
</p>
)}
</div>
{/* 비밀번호 확인 */}
<div style={{ marginBottom: '20px' }}>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
marginBottom: '6px'
}}>
비밀번호 확인 *
</label>
<input
type="password"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
placeholder="비밀번호를 다시 입력해주세요"
style={{
width: '100%',
padding: '12px',
border: validationErrors.confirmPassword ? '2px solid #ef4444' : '1px solid #d1d5db',
borderRadius: '8px',
fontSize: '14px',
outline: 'none',
transition: 'border-color 0.2s',
boxSizing: 'border-box'
}}
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
onBlur={(e) => e.target.style.borderColor = validationErrors.confirmPassword ? '#ef4444' : '#d1d5db'}
/>
{validationErrors.confirmPassword && (
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
{validationErrors.confirmPassword}
</p>
)}
</div>
{/* 이메일 (선택사항) */}
<div style={{ marginBottom: '20px' }}>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
marginBottom: '6px'
}}>
이메일 (선택사항)
</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="admin@company.com"
style={{
width: '100%',
padding: '12px',
border: validationErrors.email ? '2px solid #ef4444' : '1px solid #d1d5db',
borderRadius: '8px',
fontSize: '14px',
outline: 'none',
transition: 'border-color 0.2s',
boxSizing: 'border-box'
}}
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
onBlur={(e) => e.target.style.borderColor = validationErrors.email ? '#ef4444' : '#d1d5db'}
/>
{validationErrors.email && (
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
{validationErrors.email}
</p>
)}
</div>
{/* 부서/직책 (선택사항) */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '24px' }}>
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
marginBottom: '6px'
}}>
부서 (선택사항)
</label>
<input
type="text"
name="department"
value={formData.department}
onChange={handleChange}
placeholder="IT팀"
style={{
width: '100%',
padding: '12px',
border: '1px solid #d1d5db',
borderRadius: '8px',
fontSize: '14px',
outline: 'none',
transition: 'border-color 0.2s',
boxSizing: 'border-box'
}}
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
onBlur={(e) => e.target.style.borderColor = '#d1d5db'}
/>
</div>
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
marginBottom: '6px'
}}>
직책 (선택사항)
</label>
<input
type="text"
name="position"
value={formData.position}
onChange={handleChange}
placeholder="시스템 관리자"
style={{
width: '100%',
padding: '12px',
border: '1px solid #d1d5db',
borderRadius: '8px',
fontSize: '14px',
outline: 'none',
transition: 'border-color 0.2s',
boxSizing: 'border-box'
}}
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
onBlur={(e) => e.target.style.borderColor = '#d1d5db'}
/>
</div>
</div>
{/* 에러 메시지 */}
{error && (
<div style={{
backgroundColor: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: '8px',
padding: '12px',
marginBottom: '20px'
}}>
<p style={{ color: '#dc2626', fontSize: '14px', margin: 0 }}>
{error}
</p>
</div>
)}
{/* 제출 버튼 */}
<button
type="submit"
disabled={isLoading}
style={{
width: '100%',
padding: '14px',
backgroundColor: isLoading ? '#9ca3af' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '16px',
fontWeight: '600',
cursor: isLoading ? 'not-allowed' : 'pointer',
transition: 'background-color 0.2s',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px'
}}
onMouseEnter={(e) => {
if (!isLoading) e.target.style.backgroundColor = '#2563eb';
}}
onMouseLeave={(e) => {
if (!isLoading) e.target.style.backgroundColor = '#3b82f6';
}}
>
{isLoading ? (
<>
<div style={{
width: '16px',
height: '16px',
border: '2px solid #ffffff',
borderTop: '2px solid transparent',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}} />
설정 ...
</>
) : (
<>
🚀 시스템 초기화
</>
)}
</button>
</form>
{/* 안내 메시지 */}
<div style={{
marginTop: '24px',
padding: '16px',
backgroundColor: '#f0f9ff',
borderRadius: '8px',
border: '1px solid #bae6fd'
}}>
<p style={{
fontSize: '14px',
color: '#0369a1',
margin: 0,
lineHeight: '1.5'
}}>
💡 <strong>안내:</strong> 시스템 관리자는 모든 권한을 가지며, 다른 사용자 계정을 생성하고 관리할 있습니다.
설정 완료 계정으로 로그인하여 추가 사용자를 생성하세요.
</p>
</div>
</div>
{/* CSS 애니메이션 */}
<style jsx>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
);
};
export default SystemSetupPage;

View File

@@ -0,0 +1,266 @@
import React, { useState, useEffect } from 'react';
import BOMUploadTab from '../components/bom/tabs/BOMUploadTab';
import BOMFilesTab from '../components/bom/tabs/BOMFilesTab';
import BOMMaterialsTab from '../components/bom/tabs/BOMMaterialsTab';
const UnifiedBOMPage = ({
onNavigate,
selectedProject,
user
}) => {
const [activeTab, setActiveTab] = useState('upload');
const [selectedBOM, setSelectedBOM] = useState(null);
const [bomFiles, setBomFiles] = useState([]);
const [refreshTrigger, setRefreshTrigger] = useState(0);
// 업로드 성공 시 Files 탭으로 이동
const handleUploadSuccess = (uploadedFile) => {
setRefreshTrigger(prev => prev + 1);
setActiveTab('files');
// 업로드된 파일을 자동 선택
if (uploadedFile) {
setSelectedBOM(uploadedFile);
}
};
// BOM 파일 선택 시 Materials 탭으로 이동
const handleBOMSelect = (bomFile) => {
setSelectedBOM(bomFile);
setActiveTab('materials');
};
// 탭 정의
const tabs = [
{
id: 'upload',
label: 'Upload',
icon: '📤',
description: 'Upload new BOM files'
},
{
id: 'files',
label: 'Files & Revisions',
icon: '📊',
description: 'Manage BOM files and revisions'
},
{
id: 'materials',
label: 'Materials',
icon: '📋',
description: 'Manage classified materials',
disabled: !selectedBOM
}
];
return (
<div style={{
padding: '40px',
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
minHeight: '100vh',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
}}>
{/* 헤더 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
padding: '32px',
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)',
marginBottom: '40px'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0',
letterSpacing: '-0.025em'
}}>
BOM Management System
</h1>
<p style={{
fontSize: '16px',
color: '#64748b',
margin: 0,
fontWeight: '400'
}}>
Project: {selectedProject?.job_name || 'No Project Selected'}
{selectedBOM && (
<span style={{ marginLeft: '16px', color: '#3b82f6', fontWeight: '500' }}>
{selectedBOM.bom_name || selectedBOM.original_filename}
</span>
)}
</p>
</div>
<button
onClick={() => onNavigate('dashboard')}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '12px',
padding: '12px 20px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600',
transition: 'all 0.2s ease'
}}
>
Back to Dashboard
</button>
</div>
{/* 프로젝트 정보 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '20px'
}}>
<div style={{
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#1d4ed8', marginBottom: '4px' }}>
{selectedProject?.official_project_code || selectedProject?.job_no || 'N/A'}
</div>
<div style={{ fontSize: '14px', color: '#1d4ed8', fontWeight: '500' }}>
Project Code
</div>
</div>
<div style={{
background: 'linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#059669', marginBottom: '4px' }}>
{user?.username || 'Unknown'}
</div>
<div style={{ fontSize: '14px', color: '#059669', fontWeight: '500' }}>
Current User
</div>
</div>
<div style={{
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#d97706', marginBottom: '4px' }}>
{bomFiles.length}
</div>
<div style={{ fontSize: '14px', color: '#d97706', fontWeight: '500' }}>
BOM Files
</div>
</div>
</div>
</div>
{/* 탭 네비게이션 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
padding: '24px 32px',
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)',
marginBottom: '40px'
}}>
<div style={{ display: 'flex', gap: '8px' }}>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => !tab.disabled && setActiveTab(tab.id)}
disabled={tab.disabled}
style={{
flex: 1,
padding: '16px 24px',
background: activeTab === tab.id
? 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)'
: tab.disabled
? '#f3f4f6'
: 'white',
color: activeTab === tab.id
? 'white'
: tab.disabled
? '#9ca3af'
: '#374151',
border: activeTab === tab.id
? 'none'
: '1px solid #e5e7eb',
borderRadius: '12px',
cursor: tab.disabled ? 'not-allowed' : 'pointer',
fontSize: '16px',
fontWeight: '600',
transition: 'all 0.2s ease',
textAlign: 'center',
opacity: tab.disabled ? 0.5 : 1
}}
>
<div style={{ fontSize: '20px', marginBottom: '4px' }}>
{tab.icon}
</div>
<div>{tab.label}</div>
<div style={{
fontSize: '12px',
fontWeight: '400',
marginTop: '4px',
opacity: 0.8
}}>
{tab.description}
</div>
</button>
))}
</div>
</div>
{/* 탭 컨텐츠 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
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)',
overflow: 'hidden'
}}>
{activeTab === 'upload' && (
<BOMUploadTab
selectedProject={selectedProject}
user={user}
onUploadSuccess={handleUploadSuccess}
onNavigate={onNavigate}
/>
)}
{activeTab === 'files' && (
<BOMFilesTab
selectedProject={selectedProject}
user={user}
bomFiles={bomFiles}
setBomFiles={setBomFiles}
selectedBOM={selectedBOM}
onBOMSelect={handleBOMSelect}
refreshTrigger={refreshTrigger}
/>
)}
{activeTab === 'materials' && selectedBOM && (
<BOMMaterialsTab
selectedProject={selectedProject}
user={user}
selectedBOM={selectedBOM}
onNavigate={onNavigate}
/>
)}
</div>
</div>
);
};
export default UnifiedBOMPage;

View File

@@ -0,0 +1,323 @@
/**
* 프론트엔드 오류 로깅 시스템
* 테스트 및 디버깅을 위한 오류 수집 및 전송
*/
import api from '../api';
class ErrorLogger {
constructor() {
this.isEnabled = false; // 긴급 비활성화 - 무한 루프 방지
this.maxRetries = 3;
this.retryDelay = 1000; // 1초
this.errorQueue = [];
this.isProcessing = false;
// 전역 오류 핸들러 설정
// this.setupGlobalErrorHandlers(); // 비활성화
}
/**
* 전역 오류 핸들러 설정
*/
setupGlobalErrorHandlers() {
// JavaScript 오류 캐치
window.addEventListener('error', (event) => {
this.logError({
type: 'javascript_error',
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error?.stack,
timestamp: new Date().toISOString(),
url: window.location.href,
userAgent: navigator.userAgent
});
});
// Promise rejection 캐치
window.addEventListener('unhandledrejection', (event) => {
this.logError({
type: 'promise_rejection',
message: event.reason?.message || 'Unhandled Promise Rejection',
stack: event.reason?.stack,
timestamp: new Date().toISOString(),
url: window.location.href,
userAgent: navigator.userAgent
});
});
// React Error Boundary에서 사용할 수 있도록 전역에 등록
window.errorLogger = this;
}
/**
* 오류 로깅
* @param {Object} errorInfo - 오류 정보
*/
async logError(errorInfo) {
if (!this.isEnabled) return;
const errorData = {
...errorInfo,
sessionId: this.getSessionId(),
userId: this.getUserId(),
timestamp: errorInfo.timestamp || new Date().toISOString(),
level: errorInfo.level || 'error'
};
// 콘솔에도 출력 (개발 환경)
if (process.env.NODE_ENV === 'development') {
console.error('🚨 Frontend Error:', errorData);
}
// 로컬 스토리지에 임시 저장
this.saveToLocalStorage(errorData);
// 서버로 전송 (큐에 추가)
this.errorQueue.push(errorData);
this.processErrorQueue();
}
/**
* API 오류 로깅
* @param {Object} error - API 오류 객체
* @param {string} endpoint - API 엔드포인트
* @param {Object} requestData - 요청 데이터
*/
logApiError(error, endpoint, requestData = null) {
const errorInfo = {
type: 'api_error',
message: error.message || 'API Error',
endpoint: endpoint,
status: error.response?.status,
statusText: error.response?.statusText,
responseData: error.response?.data,
requestData: requestData,
stack: error.stack,
timestamp: new Date().toISOString(),
url: window.location.href
};
this.logError(errorInfo);
}
/**
* 사용자 액션 오류 로깅
* @param {string} action - 사용자 액션
* @param {Object} error - 오류 객체
* @param {Object} context - 추가 컨텍스트
*/
logUserActionError(action, error, context = {}) {
const errorInfo = {
type: 'user_action_error',
action: action,
message: error.message || 'User Action Error',
stack: error.stack,
context: context,
timestamp: new Date().toISOString(),
url: window.location.href
};
this.logError(errorInfo);
}
/**
* 성능 이슈 로깅
* @param {string} operation - 작업명
* @param {number} duration - 소요 시간 (ms)
* @param {Object} details - 추가 세부사항
*/
logPerformanceIssue(operation, duration, details = {}) {
if (duration > 5000) { // 5초 이상 걸린 작업만 로깅
const performanceInfo = {
type: 'performance_issue',
operation: operation,
duration: duration,
details: details,
timestamp: new Date().toISOString(),
url: window.location.href,
level: 'warning'
};
this.logError(performanceInfo);
}
}
/**
* 오류 큐 처리
*/
async processErrorQueue() {
if (this.isProcessing || this.errorQueue.length === 0) return;
this.isProcessing = true;
while (this.errorQueue.length > 0) {
const errorData = this.errorQueue.shift();
try {
await this.sendErrorToServer(errorData);
} catch (sendError) {
console.error('Failed to send error to server:', sendError);
// 실패한 오류는 다시 큐에 추가 (최대 재시도 횟수 확인)
if (!errorData.retryCount) errorData.retryCount = 0;
if (errorData.retryCount < this.maxRetries) {
errorData.retryCount++;
this.errorQueue.push(errorData);
await this.delay(this.retryDelay);
}
}
}
this.isProcessing = false;
}
/**
* 서버로 오류 전송
* @param {Object} errorData - 오류 데이터
*/
async sendErrorToServer(errorData) {
try {
await api.post('/logs/frontend-error', errorData);
} catch (error) {
// 로깅 API가 없는 경우 무시
if (error.response?.status === 404) {
console.warn('Error logging endpoint not available');
return;
}
throw error;
}
}
/**
* 로컬 스토리지에 오류 저장
* @param {Object} errorData - 오류 데이터
*/
saveToLocalStorage(errorData) {
try {
const errors = JSON.parse(localStorage.getItem('frontend_errors') || '[]');
errors.push(errorData);
// 최대 100개까지만 저장
if (errors.length > 100) {
errors.splice(0, errors.length - 100);
}
localStorage.setItem('frontend_errors', JSON.stringify(errors));
} catch (e) {
console.error('Failed to save error to localStorage:', e);
}
}
/**
* 로컬 스토리지에서 오류 목록 조회
* @returns {Array} 오류 목록
*/
getLocalErrors() {
try {
return JSON.parse(localStorage.getItem('frontend_errors') || '[]');
} catch (e) {
console.error('Failed to get errors from localStorage:', e);
return [];
}
}
/**
* 로컬 스토리지 오류 삭제
*/
clearLocalErrors() {
try {
localStorage.removeItem('frontend_errors');
} catch (e) {
console.error('Failed to clear errors from localStorage:', e);
}
}
/**
* 세션 ID 조회
* @returns {string} 세션 ID
*/
getSessionId() {
let sessionId = sessionStorage.getItem('error_session_id');
if (!sessionId) {
sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
sessionStorage.setItem('error_session_id', sessionId);
}
return sessionId;
}
/**
* 사용자 ID 조회
* @returns {string|null} 사용자 ID
*/
getUserId() {
try {
const userData = JSON.parse(localStorage.getItem('user_data') || '{}');
return userData.user_id || null;
} catch (e) {
return null;
}
}
/**
* 지연 함수
* @param {number} ms - 지연 시간 (밀리초)
*/
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 오류 로깅 활성화/비활성화
* @param {boolean} enabled - 활성화 여부
*/
setEnabled(enabled) {
this.isEnabled = enabled;
}
/**
* 수동 오류 보고
* @param {string} message - 오류 메시지
* @param {Object} details - 추가 세부사항
*/
reportError(message, details = {}) {
this.logError({
type: 'manual_report',
message: message,
details: details,
timestamp: new Date().toISOString(),
url: window.location.href,
level: 'error'
});
}
/**
* 경고 로깅
* @param {string} message - 경고 메시지
* @param {Object} details - 추가 세부사항
*/
reportWarning(message, details = {}) {
this.logError({
type: 'warning',
message: message,
details: details,
timestamp: new Date().toISOString(),
url: window.location.href,
level: 'warning'
});
}
}
// 싱글톤 인스턴스 생성 및 내보내기
const errorLogger = new ErrorLogger();
export default errorLogger;
// 편의 함수들 내보내기
export const logError = (error, context) => errorLogger.logError({ ...error, context });
export const logApiError = (error, endpoint, requestData) => errorLogger.logApiError(error, endpoint, requestData);
export const logUserActionError = (action, error, context) => errorLogger.logUserActionError(action, error, context);
export const logPerformanceIssue = (operation, duration, details) => errorLogger.logPerformanceIssue(operation, duration, details);
export const reportError = (message, details) => errorLogger.reportError(message, details);
export const reportWarning = (message, details) => errorLogger.reportWarning(message, details);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,161 @@
/**
* 자재 카테고리별 구매 수량 계산 유틸리티
*/
/**
* 파이프 구매 수량 계산
* @param {number} totalLengthMm - 파이프 총 길이 (mm)
* @param {number} quantity - BOM 수량 (개수)
* @returns {object} 구매 계산 결과
*/
export const calculatePipePurchase = (totalLengthMm, quantity) => {
if (!totalLengthMm || totalLengthMm <= 0 || !quantity || quantity <= 0) {
return {
purchaseQuantity: 0,
standardLength: 6000,
cutLength: 0,
calculation: '길이 정보 없음'
};
}
// 절단 여유분: 절단 개수만큼 3mm 추가 (백엔드와 동일)
const cuttingLoss = quantity * 3;
const requiredLength = totalLengthMm + cuttingLoss;
// 6,000mm 단위로 올림 계산
const pipeCount = Math.ceil(requiredLength / 6000);
return {
purchaseQuantity: pipeCount,
standardLength: 6000,
cutLength: requiredLength,
calculation: `${totalLengthMm}mm + ${cuttingLoss}mm(절단손실) = ${requiredLength}mm → ${pipeCount}`
};
};
/**
* 볼트/너트 구매 수량 계산
* @param {number} bomQuantity - BOM 수량
* @returns {object} 구매 계산 결과
*/
export const calculateBoltPurchase = (bomQuantity) => {
if (!bomQuantity || bomQuantity <= 0) {
return {
purchaseQuantity: 0,
calculation: '수량 정보 없음'
};
}
// +5% 여유분 후 4의 배수로 올림
const withMargin = bomQuantity * 1.05;
const purchaseQuantity = Math.ceil(withMargin / 4) * 4;
return {
purchaseQuantity: purchaseQuantity,
marginQuantity: Math.round(withMargin * 10) / 10, // 소수점 1자리
calculation: `${bomQuantity} × 1.05 = ${Math.round(withMargin * 10) / 10}${purchaseQuantity} SETS`
};
};
/**
* 가스켓 구매 수량 계산
* @param {number} bomQuantity - BOM 수량
* @returns {object} 구매 계산 결과
*/
export const calculateGasketPurchase = (bomQuantity) => {
if (!bomQuantity || bomQuantity <= 0) {
return {
purchaseQuantity: 0,
calculation: '수량 정보 없음'
};
}
// 5의 배수로 올림
const purchaseQuantity = Math.ceil(bomQuantity / 5) * 5;
return {
purchaseQuantity: purchaseQuantity,
calculation: `${bomQuantity}${purchaseQuantity} EA (5의 배수)`
};
};
/**
* 피팅/계기/밸브 구매 수량 계산
* @param {number} bomQuantity - BOM 수량
* @returns {object} 구매 계산 결과
*/
export const calculateStandardPurchase = (bomQuantity) => {
return {
purchaseQuantity: bomQuantity || 0,
calculation: `${bomQuantity || 0} EA (BOM 수량 그대로)`
};
};
/**
* 자재 카테고리별 구매 수량 계산 (통합 함수)
* @param {object} material - 자재 정보
* @returns {object} 구매 계산 결과
*/
export const calculatePurchaseQuantity = (material) => {
const category = material.classified_category || material.category || '';
const bomQuantity = material.quantity || 0;
switch (category.toUpperCase()) {
case 'PIPE':
// 파이프의 경우 길이 정보 필요
const lengthMm = material.pipe_details?.length_mm || 0;
const totalLength = material.pipe_details?.total_length_mm || (lengthMm * bomQuantity);
return {
...calculatePipePurchase(totalLength, bomQuantity),
category: 'PIPE',
unit: '본'
};
case 'BOLT':
case 'NUT':
return {
...calculateBoltPurchase(bomQuantity),
category: 'BOLT',
unit: 'SETS'
};
case 'GASKET':
return {
...calculateGasketPurchase(bomQuantity),
category: 'GASKET',
unit: 'EA'
};
case 'SUPPORT':
// 서포트는 취합된 숫자 그대로
return {
purchaseQuantity: bomQuantity,
calculation: `${bomQuantity} EA (취합된 수량 그대로)`,
category: 'SUPPORT',
unit: 'EA'
};
case 'FITTING':
case 'INSTRUMENT':
case 'VALVE':
case 'FLANGE':
default:
return {
...calculateStandardPurchase(bomQuantity),
category: category || 'STANDARD',
unit: material.unit || 'EA'
};
}
};
/**
* 자재 목록에 대한 구매 수량 계산 (일괄 처리)
* @param {Array} materials - 자재 목록
* @returns {Array} 구매 계산 결과가 포함된 자재 목록
*/
export const calculateBulkPurchase = (materials) => {
return materials.map(material => ({
...material,
purchaseInfo: calculatePurchaseQuantity(material)
}));
};