feat(tkeg): tkeg BOM 자재관리 서비스 초기 세팅 (api + web + docker-compose)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
260
tkeg/web/src/App.css
Normal file
260
tkeg/web/src/App.css
Normal 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
84
tkeg/web/src/api.js
Normal 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;
|
||||
1
tkeg/web/src/assets/react.svg
Normal file
1
tkeg/web/src/assets/react.svg
Normal 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 |
136
tkeg/web/src/components/BOMFileUpload.jsx
Normal file
136
tkeg/web/src/components/BOMFileUpload.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
|
||||
const BOMFileUpload = ({
|
||||
bomName,
|
||||
setBomName,
|
||||
selectedFile,
|
||||
setSelectedFile,
|
||||
uploading,
|
||||
handleUpload,
|
||||
error
|
||||
}) => {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: '24px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 16px 0'
|
||||
}}>
|
||||
새 BOM 업로드
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
BOM 이름 <span style={{ color: '#e53e3e' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bomName}
|
||||
onChange={(e) => setBomName(e.target.value)}
|
||||
placeholder="예: PIPING_BOM_A구역, 배관자재_1차, VALVE_LIST_Rev0"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
background: bomName ? '#f0fff4' : 'white'
|
||||
}}
|
||||
/>
|
||||
<p style={{
|
||||
fontSize: '12px',
|
||||
color: '#718096',
|
||||
margin: '4px 0 0 0'
|
||||
}}>
|
||||
💡 이 이름은 엑셀 내보내기 파일명과 자재 관리에 사용됩니다.
|
||||
동일한 BOM 이름으로 재업로드 시 리비전이 자동 증가합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => setSelectedFile(e.target.files[0])}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || !bomName.trim() || uploading}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: (!selectedFile || !bomName.trim() || uploading) ? '#e2e8f0' : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: (!selectedFile || !bomName.trim() || uploading) ? '#a0aec0' : 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: (!selectedFile || !bomName.trim() || uploading) ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '업로드'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedFile && (
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096',
|
||||
margin: '0'
|
||||
}}>
|
||||
선택된 파일: {selectedFile.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fed7d7',
|
||||
border: '1px solid #fc8181',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
marginTop: '16px',
|
||||
color: '#c53030'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMFileUpload;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
440
tkeg/web/src/components/Dashboard.jsx
Normal file
440
tkeg/web/src/components/Dashboard.jsx
Normal file
@@ -0,0 +1,440 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem
|
||||
} from '@mui/material';
|
||||
import { fetchMaterials } from '../api';
|
||||
import { Bar, Pie, Line } from 'react-chartjs-2';
|
||||
import 'chart.js/auto';
|
||||
import Toast from './Toast';
|
||||
|
||||
function Dashboard({ selectedProject, projects }) {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [barData, setBarData] = useState(null);
|
||||
const [pieData, setPieData] = useState(null);
|
||||
const [materialGradeData, setMaterialGradeData] = useState(null);
|
||||
const [sizeData, setSizeData] = useState(null);
|
||||
const [topMaterials, setTopMaterials] = useState([]);
|
||||
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
fetchMaterialStats();
|
||||
fetchMaterialList();
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
const fetchMaterialStats = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/files/materials/summary?project_id=${selectedProject.id}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStats(data.summary);
|
||||
}
|
||||
} catch (error) {
|
||||
setToast({
|
||||
open: true,
|
||||
message: '자재 통계 로드 실패',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMaterialList = async () => {
|
||||
try {
|
||||
// 최대 1000개까지 조회(실무에서는 서버 페이징/집계 API 권장)
|
||||
const params = { project_id: selectedProject.id, skip: 0, limit: 1000 };
|
||||
const response = await fetchMaterials(params);
|
||||
setMaterials(response.data.materials || []);
|
||||
} catch (error) {
|
||||
setToast({
|
||||
open: true,
|
||||
message: '자재 목록 로드 실패',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (materials.length > 0) {
|
||||
// 분류별 집계
|
||||
const typeCounts = {};
|
||||
const typeQuantities = {};
|
||||
const materialGrades = {};
|
||||
const sizes = {};
|
||||
const materialQuantities = {};
|
||||
|
||||
materials.forEach(mat => {
|
||||
const type = mat.item_type || 'OTHER';
|
||||
const grade = mat.material_grade || '미분류';
|
||||
const size = mat.size_spec || '미분류';
|
||||
const desc = mat.original_description;
|
||||
|
||||
typeCounts[type] = (typeCounts[type] || 0) + 1;
|
||||
typeQuantities[type] = (typeQuantities[type] || 0) + (mat.quantity || 0);
|
||||
materialGrades[grade] = (materialGrades[grade] || 0) + 1;
|
||||
sizes[size] = (sizes[size] || 0) + 1;
|
||||
materialQuantities[desc] = (materialQuantities[desc] || 0) + (mat.quantity || 0);
|
||||
});
|
||||
|
||||
// Bar 차트 데이터
|
||||
setBarData({
|
||||
labels: Object.keys(typeCounts),
|
||||
datasets: [
|
||||
{
|
||||
label: '자재 수',
|
||||
data: Object.values(typeCounts),
|
||||
backgroundColor: 'rgba(25, 118, 210, 0.6)',
|
||||
borderColor: 'rgba(25, 118, 210, 1)',
|
||||
borderWidth: 1
|
||||
},
|
||||
{
|
||||
label: '총 수량',
|
||||
data: Object.values(typeQuantities),
|
||||
backgroundColor: 'rgba(220, 0, 78, 0.4)',
|
||||
borderColor: 'rgba(220, 0, 78, 1)',
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// 재질별 Pie 차트
|
||||
setMaterialGradeData({
|
||||
labels: Object.keys(materialGrades),
|
||||
datasets: [{
|
||||
data: Object.values(materialGrades),
|
||||
backgroundColor: [
|
||||
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0',
|
||||
'#9966FF', '#FF9F40', '#FF6384', '#C9CBCF'
|
||||
],
|
||||
borderWidth: 2
|
||||
}]
|
||||
});
|
||||
|
||||
// 사이즈별 Pie 차트
|
||||
setSizeData({
|
||||
labels: Object.keys(sizes),
|
||||
datasets: [{
|
||||
data: Object.values(sizes),
|
||||
backgroundColor: [
|
||||
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0',
|
||||
'#9966FF', '#FF9F40', '#FF6384', '#C9CBCF'
|
||||
],
|
||||
borderWidth: 2
|
||||
}]
|
||||
});
|
||||
|
||||
// 상위 자재 (수량 기준)
|
||||
const sortedMaterials = Object.entries(materialQuantities)
|
||||
.sort(([,a], [,b]) => b - a)
|
||||
.slice(0, 10)
|
||||
.map(([desc, qty]) => ({ description: desc, quantity: qty }));
|
||||
|
||||
setTopMaterials(sortedMaterials);
|
||||
} else {
|
||||
setBarData(null);
|
||||
setMaterialGradeData(null);
|
||||
setSizeData(null);
|
||||
setTopMaterials([]);
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
📊 대시보드
|
||||
</Typography>
|
||||
|
||||
{/* 선택된 프로젝트 정보 */}
|
||||
{selectedProject && (
|
||||
<Box sx={{ mb: 3, p: 2, bgcolor: 'primary.50', borderRadius: 2, border: '1px solid', borderColor: 'primary.200' }}>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Box>
|
||||
<Typography variant="h6" color="primary">
|
||||
{selectedProject.project_name} ({selectedProject.official_project_code})
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
상태: {selectedProject.status} | 생성일: {new Date(selectedProject.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Chip
|
||||
label={selectedProject.status}
|
||||
color={selectedProject.status === 'active' ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={selectedProject.is_code_matched ? '코드 매칭됨' : '코드 미매칭'}
|
||||
color={selectedProject.is_code_matched ? 'success' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 전역 Toast */}
|
||||
<Toast
|
||||
open={toast.open}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ open: false, message: '', type: 'info' })}
|
||||
/>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* 프로젝트 현황 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" color="primary" gutterBottom>
|
||||
프로젝트 현황
|
||||
</Typography>
|
||||
<Typography variant="h3" component="div" sx={{ mb: 1 }}>
|
||||
{projects.length}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
총 프로젝트 수
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mt: 2 }}>
|
||||
선택된 프로젝트: {selectedProject.project_name}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* 자재 현황 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" color="secondary" gutterBottom>
|
||||
자재 현황
|
||||
</Typography>
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" py={3}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : stats ? (
|
||||
<Box>
|
||||
<Typography variant="h3" component="div" sx={{ mb: 1 }}>
|
||||
{stats.total_items.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
총 자재 수
|
||||
</Typography>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={6}>
|
||||
<Chip label={`고유 품목: ${stats.unique_descriptions}개`} size="small" />
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Chip label={`고유 사이즈: ${stats.unique_sizes}개`} size="small" />
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Chip label={`총 수량: ${stats.total_quantity.toLocaleString()}`} size="small" color="success" />
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Chip label={`평균 수량: ${stats.avg_quantity}`} size="small" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Typography variant="body2" sx={{ mt: 2, fontSize: '0.8rem' }}>
|
||||
최초 업로드: {stats.earliest_upload ? new Date(stats.earliest_upload).toLocaleString() : '-'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
|
||||
최신 업로드: {stats.latest_upload ? new Date(stats.latest_upload).toLocaleString() : '-'}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
프로젝트를 선택하면 자재 현황을 확인할 수 있습니다.
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* 분류별 자재 통계 */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" color="primary" gutterBottom>
|
||||
분류별 자재 통계
|
||||
</Typography>
|
||||
{barData ? (
|
||||
<Bar data={barData} options={{
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: 'top' },
|
||||
title: { display: true, text: '분류별 자재 수/총 수량' }
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}} />
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
자재 데이터가 없습니다.
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* 재질별 분포 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" color="primary" gutterBottom>
|
||||
재질별 분포
|
||||
</Typography>
|
||||
{materialGradeData ? (
|
||||
<Pie data={materialGradeData} options={{
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: 'bottom' },
|
||||
title: { display: true, text: '재질별 자재 분포' }
|
||||
}
|
||||
}} />
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
재질 데이터가 없습니다.
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* 사이즈별 분포 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" color="primary" gutterBottom>
|
||||
사이즈별 분포
|
||||
</Typography>
|
||||
{sizeData ? (
|
||||
<Pie data={sizeData} options={{
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: 'bottom' },
|
||||
title: { display: true, text: '사이즈별 자재 분포' }
|
||||
}
|
||||
}} />
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
사이즈 데이터가 없습니다.
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* 상위 자재 */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" color="primary" gutterBottom>
|
||||
상위 자재 (수량 기준)
|
||||
</Typography>
|
||||
{topMaterials.length > 0 ? (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell><strong>순위</strong></TableCell>
|
||||
<TableCell><strong>자재명</strong></TableCell>
|
||||
<TableCell align="right"><strong>총 수량</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{topMaterials.map((material, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ maxWidth: 300 }}>
|
||||
{material.description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={material.quantity.toLocaleString()}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
자재 데이터가 없습니다.
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* 프로젝트 상세 정보 */}
|
||||
{selectedProject && (
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
📋 프로젝트 상세 정보
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">프로젝트 코드</Typography>
|
||||
<Typography variant="body1">{selectedProject.official_project_code}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">프로젝트명</Typography>
|
||||
<Typography variant="body1">{selectedProject.project_name}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">상태</Typography>
|
||||
<Chip label={selectedProject.status} size="small" />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">생성일</Typography>
|
||||
<Typography variant="body1">
|
||||
{new Date(selectedProject.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
590
tkeg/web/src/components/FileManager.jsx
Normal file
590
tkeg/web/src/components/FileManager.jsx
Normal file
@@ -0,0 +1,590 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
IconButton,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Chip,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Delete,
|
||||
Download,
|
||||
Visibility,
|
||||
FileUpload,
|
||||
Warning,
|
||||
CheckCircle,
|
||||
Error,
|
||||
Update
|
||||
} from '@mui/icons-material';
|
||||
import { fetchFiles, deleteFile, uploadFile } from '../api';
|
||||
import Toast from './Toast';
|
||||
|
||||
function FileManager({ selectedProject }) {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleteDialog, setDeleteDialog] = useState({ open: false, file: null });
|
||||
const [revisionDialog, setRevisionDialog] = useState({ open: false, file: null });
|
||||
const [revisionFile, setRevisionFile] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
|
||||
const [filter, setFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
fetchFilesList();
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
// 파일 업로드 이벤트 리스너 추가
|
||||
useEffect(() => {
|
||||
console.log('FileManager: 이벤트 리스너 등록 시작');
|
||||
|
||||
const handleFileUploaded = (event) => {
|
||||
const { jobNo } = event.detail;
|
||||
console.log('FileManager: 파일 업로드 이벤트 수신:', event.detail);
|
||||
console.log('FileManager: 현재 선택된 프로젝트:', selectedProject);
|
||||
|
||||
if (selectedProject && selectedProject.job_no === jobNo) {
|
||||
console.log('FileManager: 파일 업로드 감지됨, 목록 갱신 중...');
|
||||
fetchFilesList();
|
||||
} else {
|
||||
console.log('FileManager: job_no 불일치 또는 프로젝트 미선택');
|
||||
console.log('이벤트 jobNo:', jobNo);
|
||||
console.log('선택된 프로젝트 jobNo:', selectedProject?.job_no);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('fileUploaded', handleFileUploaded);
|
||||
console.log('FileManager: fileUploaded 이벤트 리스너 등록 완료');
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('fileUploaded', handleFileUploaded);
|
||||
console.log('FileManager: fileUploaded 이벤트 리스너 제거');
|
||||
};
|
||||
}, [selectedProject]);
|
||||
|
||||
const fetchFilesList = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log('FileManager: 파일 목록 조회 시작, job_no:', selectedProject.job_no);
|
||||
const response = await fetchFiles({ job_no: selectedProject.job_no });
|
||||
console.log('FileManager: API 응답:', response.data);
|
||||
|
||||
if (response.data && response.data.files) {
|
||||
setFiles(response.data.files);
|
||||
console.log('FileManager: 파일 목록 업데이트 완료, 파일 수:', response.data.files.length);
|
||||
} else {
|
||||
console.log('FileManager: 파일 목록이 비어있음');
|
||||
setFiles([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('FileManager: 파일 목록 조회 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '파일 목록을 불러오는데 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = async () => {
|
||||
if (!deleteDialog.file) return;
|
||||
|
||||
try {
|
||||
await deleteFile(deleteDialog.file.id);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '파일이 성공적으로 삭제되었습니다.',
|
||||
type: 'success'
|
||||
});
|
||||
setDeleteDialog({ open: false, file: null });
|
||||
fetchFilesList(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error('파일 삭제 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '파일 삭제에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevisionUpload = async () => {
|
||||
if (!revisionFile || !revisionDialog.file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', revisionFile);
|
||||
formData.append('job_no', selectedProject.job_no);
|
||||
formData.append('parent_file_id', revisionDialog.file.id);
|
||||
formData.append('bom_name', revisionDialog.file.bom_name || revisionDialog.file.original_filename);
|
||||
|
||||
console.log('🔄 리비전 업로드 FormData:', {
|
||||
fileName: revisionFile.name,
|
||||
jobNo: selectedProject.job_no,
|
||||
parentFileId: revisionDialog.file.id,
|
||||
parentFileIdType: typeof revisionDialog.file.id,
|
||||
baseFileName: revisionDialog.file.original_filename,
|
||||
bomName: revisionDialog.file.bom_name || revisionDialog.file.original_filename,
|
||||
fullFileObject: revisionDialog.file
|
||||
});
|
||||
|
||||
const response = await uploadFile(formData);
|
||||
|
||||
if (response.data.success) {
|
||||
setToast({
|
||||
open: true,
|
||||
message: `리비전 업로드 성공! ${response.data.revision}`,
|
||||
type: 'success'
|
||||
});
|
||||
setRevisionDialog({ open: false, file: null });
|
||||
setRevisionFile(null);
|
||||
fetchFilesList(); // 목록 새로고침
|
||||
} else {
|
||||
setToast({
|
||||
open: true,
|
||||
message: response.data.message || '리비전 업로드에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('리비전 업로드 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '리비전 업로드에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'success';
|
||||
case 'processing':
|
||||
return 'warning';
|
||||
case 'failed':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle />;
|
||||
case 'processing':
|
||||
return <CircularProgress size={16} />;
|
||||
case 'failed':
|
||||
return <Error />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleString('ko-KR');
|
||||
};
|
||||
|
||||
const filteredFiles = files.filter(file => {
|
||||
const matchesFilter = !filter ||
|
||||
file.original_filename.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
file.project_name?.toLowerCase().includes(filter.toLowerCase());
|
||||
|
||||
const matchesStatus = !statusFilter || file.status === statusFilter;
|
||||
|
||||
return matchesFilter && matchesStatus;
|
||||
});
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
📁 도면 관리
|
||||
</Typography>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<FileUpload sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
프로젝트를 선택해주세요
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
프로젝트 관리 탭에서 프로젝트를 선택하면 도면을 관리할 수 있습니다.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
📁 도면 관리
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
|
||||
{selectedProject.project_name} ({selectedProject.official_project_code})
|
||||
</Typography>
|
||||
|
||||
{/* 필터 UI */}
|
||||
<Box sx={{ mb: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2 }}>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<TextField
|
||||
label="파일명/프로젝트명 검색"
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="파일명 또는 프로젝트명으로 검색"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>상태</InputLabel>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
label="상태"
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<MenuItem value="">전체</MenuItem>
|
||||
<MenuItem value="completed">완료</MenuItem>
|
||||
<MenuItem value="processing">처리 중</MenuItem>
|
||||
<MenuItem value="failed">실패</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={12} md={4}>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setFilter('');
|
||||
setStatusFilter('');
|
||||
}}
|
||||
>
|
||||
필터 초기화
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={fetchFilesList}
|
||||
>
|
||||
새로고침
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* 전역 Toast */}
|
||||
<Toast
|
||||
open={toast.open}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ open: false, message: '', type: 'info' })}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" sx={{ mt: 2 }}>
|
||||
파일 목록 로딩 중...
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<FileUpload sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{filter || statusFilter ? '검색 결과가 없습니다' : '업로드된 파일이 없습니다'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
{filter || statusFilter
|
||||
? '다른 검색 조건을 시도해보세요.'
|
||||
: '파일 업로드 탭에서 BOM 파일을 업로드해주세요.'
|
||||
}
|
||||
</Typography>
|
||||
{(filter || statusFilter) && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setFilter('');
|
||||
setStatusFilter('');
|
||||
}}
|
||||
>
|
||||
필터 초기화
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6">
|
||||
총 {filteredFiles.length}개 파일
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${files.length}개 전체`}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||
<TableCell><strong>번호</strong></TableCell>
|
||||
<TableCell><strong>파일명</strong></TableCell>
|
||||
<TableCell align="center"><strong>프로젝트</strong></TableCell>
|
||||
<TableCell align="center"><strong>상태</strong></TableCell>
|
||||
<TableCell align="center"><strong>파일 크기</strong></TableCell>
|
||||
<TableCell align="center"><strong>업로드 일시</strong></TableCell>
|
||||
<TableCell align="center"><strong>처리 완료</strong></TableCell>
|
||||
<TableCell align="center"><strong>작업</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredFiles.map((file, index) => (
|
||||
<TableRow
|
||||
key={file.id}
|
||||
sx={{ '&:hover': { bgcolor: 'grey.50' } }}
|
||||
>
|
||||
<TableCell>
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ maxWidth: 300 }}>
|
||||
{file.original_filename}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={file.project_name || '-'}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={file.status === 'completed' ? '완료' :
|
||||
file.status === 'processing' ? '처리 중' :
|
||||
file.status === 'failed' ? '실패' : '대기'}
|
||||
size="small"
|
||||
color={getStatusColor(file.status)}
|
||||
icon={getStatusIcon(file.status)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Typography variant="body2">
|
||||
{formatFileSize(file.file_size || 0)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Typography variant="body2">
|
||||
{formatDate(file.created_at)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Typography variant="body2">
|
||||
{file.processed_at ? formatDate(file.processed_at) : '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center' }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
title="다운로드"
|
||||
disabled={file.status !== 'completed'}
|
||||
>
|
||||
<Download />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="info"
|
||||
title="상세 보기"
|
||||
>
|
||||
<Visibility />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="warning"
|
||||
title="리비전 업로드"
|
||||
onClick={() => {
|
||||
console.log('🔄 리비전 버튼 클릭, 파일 정보:', file);
|
||||
setRevisionDialog({ open: true, file });
|
||||
}}
|
||||
>
|
||||
<Update />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
title="삭제"
|
||||
onClick={() => setDeleteDialog({ open: true, file })}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<Dialog
|
||||
open={deleteDialog.open}
|
||||
onClose={() => setDeleteDialog({ open: false, file: null })}
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Warning color="error" />
|
||||
파일 삭제 확인
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
다음 파일을 삭제하시겠습니까?
|
||||
</Typography>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>파일명:</strong> {deleteDialog.file?.original_filename}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>프로젝트:</strong> {deleteDialog.file?.project_name}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>업로드 일시:</strong> {deleteDialog.file?.created_at ? formatDate(deleteDialog.file.created_at) : '-'}
|
||||
</Typography>
|
||||
</Alert>
|
||||
<Typography variant="body2" color="error">
|
||||
⚠️ 이 작업은 되돌릴 수 없습니다. 파일과 관련된 모든 자재 데이터가 함께 삭제됩니다.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => setDeleteDialog({ open: false, file: null })}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeleteFile}
|
||||
color="error"
|
||||
variant="contained"
|
||||
startIcon={<Delete />}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 리비전 업로드 다이얼로그 */}
|
||||
<Dialog
|
||||
open={revisionDialog.open}
|
||||
onClose={() => {
|
||||
setRevisionDialog({ open: false, file: null });
|
||||
setRevisionFile(null);
|
||||
}}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>리비전 업로드</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
<strong>기준 파일:</strong> {revisionDialog.file?.original_filename}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||
현재 리비전: {revisionDialog.file?.revision || 'Rev.0'}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
새 리비전 파일을 선택하세요:
|
||||
</Typography>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={(e) => setRevisionFile(e.target.files[0])}
|
||||
style={{ width: '100%', padding: '8px' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{revisionFile && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
선택된 파일: {revisionFile.name}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setRevisionDialog({ open: false, file: null });
|
||||
setRevisionFile(null);
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRevisionUpload}
|
||||
variant="contained"
|
||||
disabled={!revisionFile || uploading}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '리비전 업로드'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileManager;
|
||||
579
tkeg/web/src/components/FileUpload.jsx
Normal file
579
tkeg/web/src/components/FileUpload.jsx
Normal file
@@ -0,0 +1,579 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
LinearProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Chip,
|
||||
Paper,
|
||||
Divider,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
StepContent,
|
||||
Alert,
|
||||
Grid
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CloudUpload,
|
||||
AttachFile,
|
||||
CheckCircle,
|
||||
Error as ErrorIcon,
|
||||
Description,
|
||||
AutoAwesome,
|
||||
Category,
|
||||
Science,
|
||||
Compare
|
||||
} from '@mui/icons-material';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { uploadFile as uploadFileApi, fetchMaterialsSummary } from '../api';
|
||||
import Toast from './Toast';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function FileUpload({ selectedProject, onUploadSuccess }) {
|
||||
console.log('=== FileUpload 컴포넌트 렌더링 ===');
|
||||
console.log('selectedProject:', selectedProject);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadResult, setUploadResult] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [showError, setShowError] = useState(false);
|
||||
const [materialsSummary, setMaterialsSummary] = useState(null);
|
||||
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
|
||||
const [uploadSteps, setUploadSteps] = useState([
|
||||
{ label: '파일 업로드', completed: false, active: false },
|
||||
{ label: '데이터 파싱', completed: false, active: false },
|
||||
{ label: '자재 분류', completed: false, active: false },
|
||||
{ label: '분류기 실행', completed: false, active: false },
|
||||
{ label: '데이터베이스 저장', completed: false, active: false }
|
||||
]);
|
||||
|
||||
const onDrop = useCallback((acceptedFiles) => {
|
||||
console.log('=== FileUpload: onDrop 함수 호출됨 ===');
|
||||
console.log('받은 파일들:', acceptedFiles);
|
||||
console.log('선택된 프로젝트:', selectedProject);
|
||||
|
||||
if (!selectedProject) {
|
||||
console.log('프로젝트가 선택되지 않음');
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트를 먼저 선택해주세요.',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (acceptedFiles.length > 0) {
|
||||
console.log('파일 업로드 시작');
|
||||
uploadFile(acceptedFiles[0]);
|
||||
} else {
|
||||
console.log('선택된 파일이 없음');
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, acceptedFiles } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||
'application/vnd.ms-excel': ['.xls'],
|
||||
'text/csv': ['.csv']
|
||||
},
|
||||
multiple: false,
|
||||
maxSize: 10 * 1024 * 1024 // 10MB
|
||||
});
|
||||
|
||||
const updateUploadStep = (stepIndex, completed = false, active = false) => {
|
||||
setUploadSteps(prev => prev.map((step, index) => ({
|
||||
...step,
|
||||
completed: index < stepIndex ? true : (index === stepIndex ? completed : false),
|
||||
active: index === stepIndex ? active : false
|
||||
})));
|
||||
};
|
||||
|
||||
const uploadFile = async (file) => {
|
||||
console.log('=== FileUpload: uploadFile 함수 시작 ===');
|
||||
console.log('파일 정보:', {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
});
|
||||
console.log('선택된 프로젝트:', selectedProject);
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
setError('');
|
||||
setUploadResult(null);
|
||||
setMaterialsSummary(null);
|
||||
|
||||
console.log('업로드 시작:', {
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
jobNo: selectedProject?.job_no,
|
||||
projectName: selectedProject?.project_name
|
||||
});
|
||||
|
||||
// 업로드 단계 초기화
|
||||
setUploadSteps([
|
||||
{ label: '파일 업로드', completed: false, active: true },
|
||||
{ label: '데이터 파싱', completed: false, active: false },
|
||||
{ label: '자재 분류', completed: false, active: false },
|
||||
{ label: '분류기 실행', completed: false, active: false },
|
||||
{ label: '데이터베이스 저장', completed: false, active: false }
|
||||
]);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('job_no', selectedProject.job_no);
|
||||
formData.append('revision', 'Rev.0'); // 새 BOM은 항상 Rev.0
|
||||
formData.append('bom_name', file.name); // BOM 이름으로 파일명 사용
|
||||
formData.append('bom_type', 'excel'); // 파일 타입
|
||||
formData.append('description', ''); // 설명 (빈 문자열)
|
||||
|
||||
console.log('FormData 내용:', {
|
||||
fileName: file.name,
|
||||
jobNo: selectedProject.job_no,
|
||||
revision: 'Rev.0', // 새 BOM은 항상 Rev.0
|
||||
bomName: file.name,
|
||||
bomType: 'excel'
|
||||
});
|
||||
|
||||
try {
|
||||
// 1단계: 파일 업로드
|
||||
updateUploadStep(0, true, false);
|
||||
updateUploadStep(1, false, true);
|
||||
|
||||
console.log('API 호출 시작: /upload');
|
||||
const response = await uploadFileApi(formData, {
|
||||
onUploadProgress: (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const progress = Math.round((event.loaded / event.total) * 100);
|
||||
setUploadProgress(progress);
|
||||
console.log('업로드 진행률:', progress + '%');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('API 응답:', response.data);
|
||||
const result = response.data;
|
||||
|
||||
console.log('응답 데이터 구조:', {
|
||||
success: result.success,
|
||||
file_id: result.file_id,
|
||||
message: result.message,
|
||||
hasFileId: 'file_id' in result
|
||||
});
|
||||
|
||||
// 2단계: 데이터 파싱 완료
|
||||
updateUploadStep(1, true, false);
|
||||
updateUploadStep(2, false, true);
|
||||
|
||||
if (result.success) {
|
||||
// 3단계: 자재 분류 완료
|
||||
updateUploadStep(2, true, false);
|
||||
updateUploadStep(3, false, true);
|
||||
|
||||
// 4단계: 분류기 실행 완료
|
||||
updateUploadStep(3, true, false);
|
||||
updateUploadStep(4, false, true);
|
||||
|
||||
// 5단계: 데이터베이스 저장 완료
|
||||
updateUploadStep(4, true, false);
|
||||
|
||||
setUploadResult(result);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '파일 업로드 및 분류가 성공했습니다!',
|
||||
type: 'success'
|
||||
});
|
||||
|
||||
console.log('업로드 성공 결과:', result);
|
||||
console.log('파일 ID:', result.file_id);
|
||||
console.log('선택된 프로젝트:', selectedProject);
|
||||
|
||||
// 업로드 성공 후 자재 통계 미리보기 호출
|
||||
try {
|
||||
const summaryRes = await fetchMaterialsSummary({ file_id: result.file_id });
|
||||
if (summaryRes.data && summaryRes.data.success) {
|
||||
setMaterialsSummary(summaryRes.data.summary);
|
||||
}
|
||||
} catch (e) {
|
||||
// 통계 조회 실패는 무시(UX만)
|
||||
}
|
||||
|
||||
if (onUploadSuccess) {
|
||||
console.log('onUploadSuccess 콜백 호출');
|
||||
onUploadSuccess(result);
|
||||
}
|
||||
|
||||
// 파일 목록 갱신을 위한 이벤트 발생
|
||||
console.log('파일 업로드 이벤트 발생:', {
|
||||
fileId: result.file_id,
|
||||
jobNo: selectedProject.job_no
|
||||
});
|
||||
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('fileUploaded', {
|
||||
detail: { fileId: result.file_id, jobNo: selectedProject.job_no }
|
||||
}));
|
||||
console.log('CustomEvent dispatch 성공');
|
||||
} catch (error) {
|
||||
console.error('CustomEvent dispatch 실패:', error);
|
||||
}
|
||||
} else {
|
||||
setToast({
|
||||
open: true,
|
||||
message: result.message || '업로드에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('업로드 실패:', error);
|
||||
|
||||
// 에러 타입별 상세 메시지
|
||||
let errorMessage = '업로드에 실패했습니다.';
|
||||
|
||||
if (error.response) {
|
||||
// 서버 응답이 있는 경우
|
||||
const status = error.response.status;
|
||||
const data = error.response.data;
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
errorMessage = `잘못된 요청: ${data?.detail || '파일 형식이나 데이터를 확인해주세요.'}`;
|
||||
break;
|
||||
case 413:
|
||||
errorMessage = '파일 크기가 너무 큽니다. (최대 10MB)';
|
||||
break;
|
||||
case 422:
|
||||
errorMessage = `데이터 검증 실패: ${data?.detail || '파일 내용을 확인해주세요.'}`;
|
||||
break;
|
||||
case 500:
|
||||
errorMessage = '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
||||
break;
|
||||
default:
|
||||
errorMessage = `서버 오류 (${status}): ${data?.detail || error.message}`;
|
||||
}
|
||||
} else if (error.request) {
|
||||
// 네트워크 오류
|
||||
errorMessage = '서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.';
|
||||
} else {
|
||||
// 기타 오류
|
||||
errorMessage = `오류 발생: ${error.message}`;
|
||||
}
|
||||
|
||||
setToast({
|
||||
open: true,
|
||||
message: errorMessage,
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
uploadFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const resetUpload = () => {
|
||||
setUploadResult(null);
|
||||
setUploadProgress(0);
|
||||
setUploadSteps([
|
||||
{ label: '파일 업로드', completed: false, active: false },
|
||||
{ label: '데이터 파싱', completed: false, active: false },
|
||||
{ label: '자재 분류', completed: false, active: false },
|
||||
{ label: '분류기 실행', completed: false, active: false },
|
||||
{ label: '데이터베이스 저장', completed: false, active: false }
|
||||
]);
|
||||
setToast({ open: false, message: '', type: 'info' });
|
||||
};
|
||||
|
||||
const getClassificationStats = () => {
|
||||
if (!uploadResult?.classification_stats) return null;
|
||||
|
||||
const stats = uploadResult.classification_stats;
|
||||
const total = Object.values(stats).reduce((sum, count) => sum + count, 0);
|
||||
|
||||
return Object.entries(stats)
|
||||
.filter(([category, count]) => count > 0)
|
||||
.map(([category, count]) => ({
|
||||
category,
|
||||
count,
|
||||
percentage: total > 0 ? Math.round((count / total) * 100) : 0
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
📁 파일 업로드
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
|
||||
{selectedProject.project_name} ({selectedProject.official_project_code})
|
||||
</Typography>
|
||||
|
||||
{/* 전역 Toast */}
|
||||
<Toast
|
||||
open={toast.open}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ open: false, message: '', type: 'info' })}
|
||||
/>
|
||||
|
||||
{uploading && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<AutoAwesome sx={{ mr: 1, color: 'primary.main' }} />
|
||||
업로드 및 분류 진행 중...
|
||||
</Typography>
|
||||
|
||||
<Stepper orientation="vertical" sx={{ mt: 2 }}>
|
||||
{uploadSteps.map((step, index) => (
|
||||
<Step key={index} active={step.active} completed={step.completed}>
|
||||
<StepLabel>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{step.completed ? (
|
||||
<CheckCircle color="success" sx={{ mr: 1 }} />
|
||||
) : step.active ? (
|
||||
<Science color="primary" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<Category color="disabled" sx={{ mr: 1 }} />
|
||||
)}
|
||||
{step.label}
|
||||
</Box>
|
||||
</StepLabel>
|
||||
{step.active && (
|
||||
<StepContent>
|
||||
<LinearProgress sx={{ mt: 1 }} />
|
||||
</StepContent>
|
||||
)}
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
{uploadProgress > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||
파일 업로드 진행률: {uploadProgress}%
|
||||
</Typography>
|
||||
<LinearProgress variant="determinate" value={uploadProgress} />
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{uploadResult ? (
|
||||
<Card sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<CheckCircle color="success" sx={{ mr: 1 }} />
|
||||
<Typography variant="h6" color="success.main">
|
||||
업로드 및 분류 성공!
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
📊 업로드 결과
|
||||
</Typography>
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Description />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="파일명"
|
||||
secondary={uploadResult.original_filename}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckCircle />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="파싱된 자재 수"
|
||||
secondary={`${uploadResult.parsed_materials_count}개`}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckCircle />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="저장된 자재 수"
|
||||
secondary={`${uploadResult.saved_materials_count}개`}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
🏷️ 분류 결과
|
||||
</Typography>
|
||||
{getClassificationStats() && (
|
||||
<Box>
|
||||
{getClassificationStats().map((stat, index) => (
|
||||
<Box key={index} sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Chip
|
||||
label={stat.category}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Typography variant="body2">
|
||||
{stat.count}개 ({stat.percentage}%)
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{materialsSummary && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
<Typography variant="body2">
|
||||
💡 <strong>자재 통계 미리보기:</strong><br/>
|
||||
• 총 자재 수: {materialsSummary.total_items || 0}개<br/>
|
||||
• 고유 자재: {materialsSummary.unique_descriptions || 0}종류<br/>
|
||||
• 총 수량: {materialsSummary.total_quantity || 0}개
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
// 상태 기반 라우팅을 위한 이벤트 발생
|
||||
window.dispatchEvent(new CustomEvent('navigateToMaterials', {
|
||||
detail: {
|
||||
jobNo: selectedProject?.job_no,
|
||||
revision: uploadResult?.revision || 'Rev.0',
|
||||
bomName: uploadResult?.original_filename || uploadResult?.filename,
|
||||
message: '파일 업로드 완료',
|
||||
file_id: uploadResult?.file_id // file_id 추가
|
||||
}
|
||||
}));
|
||||
}}
|
||||
startIcon={<Description />}
|
||||
>
|
||||
자재 목록 보기
|
||||
</Button>
|
||||
|
||||
{uploadResult?.revision && uploadResult.revision !== 'Rev.0' && uploadResult.revision !== undefined && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={() => navigate(`/material-comparison?job_no=${selectedProject.job_no}&revision=${uploadResult.revision}&filename=${encodeURIComponent(uploadResult.original_filename || uploadResult.filename)}`)}
|
||||
startIcon={<Compare />}
|
||||
>
|
||||
이전 리비전과 비교 ({uploadResult.revision})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={resetUpload}
|
||||
>
|
||||
새로 업로드
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Paper
|
||||
{...getRootProps()}
|
||||
onClick={() => console.log('드래그 앤 드롭 영역 클릭됨')}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: 2,
|
||||
borderStyle: 'dashed',
|
||||
borderColor: isDragActive ? 'primary.main' : 'grey.300',
|
||||
bgcolor: isDragActive ? 'primary.50' : 'grey.50',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
bgcolor: 'primary.50'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<CloudUpload sx={{
|
||||
fontSize: 64,
|
||||
color: isDragActive ? 'primary.main' : 'grey.400',
|
||||
mb: 2
|
||||
}} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{isDragActive
|
||||
? "파일을 여기에 놓으세요!"
|
||||
: "Excel 파일을 드래그하거나 클릭하여 선택"
|
||||
}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
지원 형식: .xlsx, .xls, .csv (최대 10MB)
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AttachFile />}
|
||||
component="span"
|
||||
disabled={uploading}
|
||||
onClick={() => console.log('파일 선택 버튼 클릭됨')}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '파일 선택'}
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
💡 <strong>업로드 및 분류 프로세스:</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
• BOM(Bill of Materials) 파일을 업로드하면 자동으로 자재 정보를 추출합니다
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
• 각 자재는 8가지 분류기(볼트, 플랜지, 피팅, 가스켓, 계기, 파이프, 밸브, 재질)로 자동 분류됩니다
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
• 분류 결과는 신뢰도 점수와 함께 저장되며, 필요시 수동 검증이 가능합니다
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
FileUpload.propTypes = {
|
||||
selectedProject: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
project_name: PropTypes.string.isRequired,
|
||||
official_project_code: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
onUploadSuccess: PropTypes.func,
|
||||
};
|
||||
|
||||
export default FileUpload;
|
||||
99
tkeg/web/src/components/FittingDetailsCard.jsx
Normal file
99
tkeg/web/src/components/FittingDetailsCard.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Box, Grid, Chip, Divider } from '@mui/material';
|
||||
|
||||
const FittingDetailsCard = ({ material }) => {
|
||||
const fittingDetails = material.fitting_details || {};
|
||||
|
||||
return (
|
||||
<Card sx={{ mt: 2, backgroundColor: '#f5f5f5' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
🔗 FITTING 상세 정보
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`신뢰도: ${Math.round((material.classification_confidence || 0) * 100)}%`}
|
||||
color={material.classification_confidence >= 0.8 ? 'success' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" color="text.secondary">자재명</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 'medium' }}>
|
||||
{material.original_description}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">피팅 타입</Typography>
|
||||
<Typography variant="body1">
|
||||
{fittingDetails.fitting_type || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">세부 타입</Typography>
|
||||
<Typography variant="body1">
|
||||
{fittingDetails.fitting_subtype || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">연결 방식</Typography>
|
||||
<Typography variant="body1">
|
||||
{fittingDetails.connection_method || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">압력 등급</Typography>
|
||||
<Typography variant="body1">
|
||||
{fittingDetails.pressure_rating || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">재질 규격</Typography>
|
||||
<Typography variant="body1">
|
||||
{fittingDetails.material_standard || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">재질 등급</Typography>
|
||||
<Typography variant="body1">
|
||||
{fittingDetails.material_grade || material.material_grade || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">주 사이즈</Typography>
|
||||
<Typography variant="body1">
|
||||
{fittingDetails.main_size || material.size_spec || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">축소 사이즈</Typography>
|
||||
<Typography variant="body1">
|
||||
{fittingDetails.reduced_size || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" color="text.secondary">수량</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
|
||||
{material.quantity} {material.unit}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default FittingDetailsCard;
|
||||
516
tkeg/web/src/components/MaterialComparisonResult.jsx
Normal file
516
tkeg/web/src/components/MaterialComparisonResult.jsx
Normal file
@@ -0,0 +1,516 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Chip,
|
||||
Alert,
|
||||
Tabs,
|
||||
Tab,
|
||||
Button,
|
||||
Checkbox,
|
||||
TextField,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Stack,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
ShoppingCart as ShoppingCartIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
Remove as RemoveIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
const MaterialComparisonResult = ({
|
||||
comparison,
|
||||
onConfirmPurchase,
|
||||
loading = false
|
||||
}) => {
|
||||
const [selectedTab, setSelectedTab] = useState(0);
|
||||
const [selectedItems, setSelectedItems] = useState(new Set());
|
||||
const [confirmDialog, setConfirmDialog] = useState(false);
|
||||
const [purchaseConfirmations, setPurchaseConfirmations] = useState({});
|
||||
|
||||
if (!comparison || !comparison.success) {
|
||||
return (
|
||||
<Alert severity="info">
|
||||
비교할 이전 리비전이 없거나 비교 데이터를 불러올 수 없습니다.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary, new_items, modified_items, removed_items, purchase_summary } = comparison;
|
||||
|
||||
// 탭 변경 핸들러
|
||||
const handleTabChange = (event, newValue) => {
|
||||
setSelectedTab(newValue);
|
||||
setSelectedItems(new Set()); // 탭 변경시 선택 초기화
|
||||
};
|
||||
|
||||
// 아이템 선택 핸들러
|
||||
const handleItemSelect = (materialHash, checked) => {
|
||||
const newSelected = new Set(selectedItems);
|
||||
if (checked) {
|
||||
newSelected.add(materialHash);
|
||||
} else {
|
||||
newSelected.delete(materialHash);
|
||||
}
|
||||
setSelectedItems(newSelected);
|
||||
};
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleSelectAll = (items, checked) => {
|
||||
const newSelected = new Set(selectedItems);
|
||||
items.forEach(item => {
|
||||
if (checked) {
|
||||
newSelected.add(item.material_hash);
|
||||
} else {
|
||||
newSelected.delete(item.material_hash);
|
||||
}
|
||||
});
|
||||
setSelectedItems(newSelected);
|
||||
};
|
||||
|
||||
// 발주 확정 다이얼로그 열기
|
||||
const handleOpenConfirmDialog = () => {
|
||||
const confirmations = {};
|
||||
|
||||
// 선택된 신규 항목
|
||||
new_items.forEach(item => {
|
||||
if (selectedItems.has(item.material_hash)) {
|
||||
confirmations[item.material_hash] = {
|
||||
material_hash: item.material_hash,
|
||||
description: item.description,
|
||||
confirmed_quantity: item.additional_needed,
|
||||
supplier_name: '',
|
||||
unit_price: 0
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 선택된 변경 항목 (추가 필요량만)
|
||||
modified_items.forEach(item => {
|
||||
if (selectedItems.has(item.material_hash) && item.additional_needed > 0) {
|
||||
confirmations[item.material_hash] = {
|
||||
material_hash: item.material_hash,
|
||||
description: item.description,
|
||||
confirmed_quantity: item.additional_needed,
|
||||
supplier_name: '',
|
||||
unit_price: 0
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
setPurchaseConfirmations(confirmations);
|
||||
setConfirmDialog(true);
|
||||
};
|
||||
|
||||
// 발주 확정 실행
|
||||
const handleConfirmPurchase = () => {
|
||||
const confirmationList = Object.values(purchaseConfirmations).filter(
|
||||
conf => conf.confirmed_quantity > 0
|
||||
);
|
||||
|
||||
if (confirmationList.length > 0) {
|
||||
onConfirmPurchase?.(confirmationList);
|
||||
}
|
||||
|
||||
setConfirmDialog(false);
|
||||
setSelectedItems(new Set());
|
||||
};
|
||||
|
||||
// 수량 변경 핸들러
|
||||
const handleQuantityChange = (materialHash, quantity) => {
|
||||
setPurchaseConfirmations(prev => ({
|
||||
...prev,
|
||||
[materialHash]: {
|
||||
...prev[materialHash],
|
||||
confirmed_quantity: parseFloat(quantity) || 0
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
// 공급업체 변경 핸들러
|
||||
const handleSupplierChange = (materialHash, supplier) => {
|
||||
setPurchaseConfirmations(prev => ({
|
||||
...prev,
|
||||
[materialHash]: {
|
||||
...prev[materialHash],
|
||||
supplier_name: supplier
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
// 단가 변경 핸들러
|
||||
const handlePriceChange = (materialHash, price) => {
|
||||
setPurchaseConfirmations(prev => ({
|
||||
...prev,
|
||||
[materialHash]: {
|
||||
...prev[materialHash],
|
||||
unit_price: parseFloat(price) || 0
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const renderSummaryCard = () => (
|
||||
<Card sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
리비전 비교 요약
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap">
|
||||
<Chip
|
||||
icon={<AddIcon />}
|
||||
label={`신규: ${summary.new_items_count}개`}
|
||||
color="success"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
icon={<EditIcon />}
|
||||
label={`변경: ${summary.modified_items_count}개`}
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
icon={<RemoveIcon />}
|
||||
label={`삭제: ${summary.removed_items_count}개`}
|
||||
color="error"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{purchase_summary.additional_purchase_needed > 0 && (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>추가 발주 필요:</strong> {purchase_summary.additional_purchase_needed}개 항목
|
||||
(신규 {purchase_summary.total_new_items}개 + 증량 {purchase_summary.total_increased_items}개)
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderNewItemsTable = () => (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
indeterminate={selectedItems.size > 0 && selectedItems.size < new_items.length}
|
||||
checked={new_items.length > 0 && selectedItems.size === new_items.length}
|
||||
onChange={(e) => handleSelectAll(new_items, e.target.checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>품명</TableCell>
|
||||
<TableCell>사이즈</TableCell>
|
||||
<TableCell align="right">필요수량</TableCell>
|
||||
<TableCell align="right">기존재고</TableCell>
|
||||
<TableCell align="right">추가필요</TableCell>
|
||||
<TableCell>재질</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{new_items.map((item, index) => (
|
||||
<TableRow
|
||||
key={item.material_hash}
|
||||
selected={selectedItems.has(item.material_hash)}
|
||||
hover
|
||||
>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
checked={selectedItems.has(item.material_hash)}
|
||||
onChange={(e) => handleItemSelect(item.material_hash, e.target.checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{item.description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{item.size_spec}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={item.quantity}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">{item.available_stock}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={item.additional_needed}
|
||||
size="small"
|
||||
color={item.additional_needed > 0 ? "error" : "success"}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
{item.material_grade}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
|
||||
const renderModifiedItemsTable = () => (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
indeterminate={selectedItems.size > 0 && selectedItems.size < modified_items.length}
|
||||
checked={modified_items.length > 0 && selectedItems.size === modified_items.length}
|
||||
onChange={(e) => handleSelectAll(modified_items, e.target.checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>품명</TableCell>
|
||||
<TableCell>사이즈</TableCell>
|
||||
<TableCell align="right">이전수량</TableCell>
|
||||
<TableCell align="right">현재수량</TableCell>
|
||||
<TableCell align="right">증감</TableCell>
|
||||
<TableCell align="right">기존재고</TableCell>
|
||||
<TableCell align="right">추가필요</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{modified_items.map((item, index) => (
|
||||
<TableRow
|
||||
key={item.material_hash}
|
||||
selected={selectedItems.has(item.material_hash)}
|
||||
hover
|
||||
>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
checked={selectedItems.has(item.material_hash)}
|
||||
onChange={(e) => handleItemSelect(item.material_hash, e.target.checked)}
|
||||
disabled={item.additional_needed <= 0} // 추가 필요량이 없으면 선택 불가
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{item.description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{item.size_spec}</TableCell>
|
||||
<TableCell align="right">{item.previous_quantity}</TableCell>
|
||||
<TableCell align="right">{item.current_quantity}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={item.quantity_diff > 0 ? `+${item.quantity_diff}` : item.quantity_diff}
|
||||
size="small"
|
||||
color={item.quantity_diff > 0 ? "error" : "success"}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">{item.available_stock}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={item.additional_needed}
|
||||
size="small"
|
||||
color={item.additional_needed > 0 ? "error" : "success"}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
|
||||
const renderRemovedItemsTable = () => (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>품명</TableCell>
|
||||
<TableCell>사이즈</TableCell>
|
||||
<TableCell align="right">삭제된 수량</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{removed_items.map((item, index) => (
|
||||
<TableRow key={item.material_hash}>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{item.description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{item.size_spec}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={item.quantity}
|
||||
size="small"
|
||||
color="default"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
|
||||
const renderConfirmDialog = () => (
|
||||
<Dialog
|
||||
open={confirmDialog}
|
||||
onClose={() => setConfirmDialog(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<ShoppingCartIcon />
|
||||
<Typography variant="h6">발주 확정</Typography>
|
||||
</Stack>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
선택한 {Object.keys(purchaseConfirmations).length}개 항목의 발주를 확정합니다.
|
||||
수량과 공급업체 정보를 확인해주세요.
|
||||
</Typography>
|
||||
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>품명</TableCell>
|
||||
<TableCell align="right">확정수량</TableCell>
|
||||
<TableCell>공급업체</TableCell>
|
||||
<TableCell align="right">단가</TableCell>
|
||||
<TableCell align="right">총액</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Object.values(purchaseConfirmations).map((conf) => (
|
||||
<TableRow key={conf.material_hash}>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{conf.description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
value={conf.confirmed_quantity}
|
||||
onChange={(e) => handleQuantityChange(conf.material_hash, e.target.value)}
|
||||
sx={{ width: 80 }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="공급업체"
|
||||
value={conf.supplier_name}
|
||||
onChange={(e) => handleSupplierChange(conf.material_hash, e.target.value)}
|
||||
sx={{ width: 120 }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={conf.unit_price}
|
||||
onChange={(e) => handlePriceChange(conf.material_hash, e.target.value)}
|
||||
sx={{ width: 80 }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography variant="body2">
|
||||
{(conf.confirmed_quantity * conf.unit_price).toLocaleString()}원
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmDialog(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmPurchase}
|
||||
variant="contained"
|
||||
startIcon={<CheckCircleIcon />}
|
||||
disabled={loading}
|
||||
>
|
||||
발주 확정
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{renderSummaryCard()}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="자재 비교 상세"
|
||||
action={
|
||||
selectedItems.size > 0 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<ShoppingCartIcon />}
|
||||
onClick={handleOpenConfirmDialog}
|
||||
disabled={loading}
|
||||
>
|
||||
선택 항목 발주 확정 ({selectedItems.size}개)
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<CardContent>
|
||||
<Tabs value={selectedTab} onChange={handleTabChange}>
|
||||
<Tab
|
||||
label={`신규 항목 (${new_items.length})`}
|
||||
icon={<AddIcon />}
|
||||
/>
|
||||
<Tab
|
||||
label={`수량 변경 (${modified_items.length})`}
|
||||
icon={<EditIcon />}
|
||||
/>
|
||||
<Tab
|
||||
label={`삭제 항목 (${removed_items.length})`}
|
||||
icon={<RemoveIcon />}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{selectedTab === 0 && renderNewItemsTable()}
|
||||
{selectedTab === 1 && renderModifiedItemsTable()}
|
||||
{selectedTab === 2 && renderRemovedItemsTable()}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{renderConfirmDialog()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialComparisonResult;
|
||||
855
tkeg/web/src/components/MaterialList.jsx
Normal file
855
tkeg/web/src/components/MaterialList.jsx
Normal file
@@ -0,0 +1,855 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
TablePagination,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
TextField,
|
||||
MenuItem,
|
||||
Select,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
Grid,
|
||||
IconButton,
|
||||
Button,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Tabs,
|
||||
Tab,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Inventory,
|
||||
Clear,
|
||||
ExpandMore,
|
||||
CompareArrows,
|
||||
Add,
|
||||
Remove,
|
||||
Warning
|
||||
} from '@mui/icons-material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { fetchMaterials as fetchMaterialsApi, fetchJobs } from '../api';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import Toast from './Toast';
|
||||
|
||||
function MaterialList({ selectedProject }) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(25);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [search, setSearch] = useState(searchParams.get('search') || '');
|
||||
const [searchValue, setSearchValue] = useState(searchParams.get('searchValue') || '');
|
||||
const [itemType, setItemType] = useState(searchParams.get('itemType') || '');
|
||||
const [materialGrade, setMaterialGrade] = useState(searchParams.get('materialGrade') || '');
|
||||
const [sizeSpec, setSizeSpec] = useState(searchParams.get('sizeSpec') || '');
|
||||
const [fileFilter, setFileFilter] = useState(searchParams.get('fileFilter') || '');
|
||||
const [selectedJob, setSelectedJob] = useState(searchParams.get('jobId') || '');
|
||||
const [selectedRevision, setSelectedRevision] = useState(searchParams.get('revision') || '');
|
||||
const [groupingType, setGroupingType] = useState(searchParams.get('grouping') || 'item');
|
||||
const [sortBy, setSortBy] = useState(searchParams.get('sortBy') || '');
|
||||
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
|
||||
const [revisionComparison, setRevisionComparison] = useState(null);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [fileId, setFileId] = useState(searchParams.get('file_id') || '');
|
||||
const [selectedJobNo, setSelectedJobNo] = useState(searchParams.get('job_no') || '');
|
||||
const [selectedFilename, setSelectedFilename] = useState(searchParams.get('filename') || '');
|
||||
|
||||
// URL 쿼리 파라미터 동기화
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('search', search);
|
||||
if (searchValue) params.set('searchValue', searchValue);
|
||||
if (itemType) params.set('itemType', itemType);
|
||||
if (materialGrade) params.set('materialGrade', materialGrade);
|
||||
if (sizeSpec) params.set('sizeSpec', sizeSpec);
|
||||
if (fileFilter) params.set('fileFilter', fileFilter);
|
||||
if (selectedJob) params.set('jobId', selectedJob);
|
||||
if (selectedRevision) params.set('revision', selectedRevision);
|
||||
if (groupingType) params.set('grouping', groupingType);
|
||||
if (sortBy) params.set('sortBy', sortBy);
|
||||
if (page > 0) params.set('page', page.toString());
|
||||
if (rowsPerPage !== 25) params.set('rowsPerPage', rowsPerPage.toString());
|
||||
if (fileId) params.set('file_id', fileId);
|
||||
if (selectedJobNo) params.set('job_no', selectedJobNo);
|
||||
if (selectedFilename) params.set('filename', selectedFilename);
|
||||
if (selectedRevision) params.set('revision', selectedRevision);
|
||||
|
||||
setSearchParams(params);
|
||||
}, [search, searchValue, itemType, materialGrade, sizeSpec, fileFilter, selectedJob, selectedRevision, groupingType, sortBy, page, rowsPerPage, fileId, setSearchParams, selectedJobNo, selectedFilename, selectedRevision]);
|
||||
|
||||
// URL 파라미터로 진입 시 자동 필터 적용
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlJobNo = urlParams.get('job_no');
|
||||
const urlFilename = urlParams.get('filename');
|
||||
const urlRevision = urlParams.get('revision');
|
||||
if (urlJobNo) setSelectedJobNo(urlJobNo);
|
||||
if (urlFilename) setSelectedFilename(urlFilename);
|
||||
if (urlRevision) setSelectedRevision(urlRevision);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
fetchJobsData();
|
||||
fetchMaterials();
|
||||
} else {
|
||||
setMaterials([]);
|
||||
setTotalCount(0);
|
||||
setJobs([]);
|
||||
}
|
||||
}, [selectedProject, page, rowsPerPage, search, searchValue, itemType, materialGrade, sizeSpec, fileFilter, selectedJob, selectedRevision, groupingType, sortBy, fileId, selectedJobNo, selectedFilename, selectedRevision]);
|
||||
|
||||
// 파일 업로드 이벤트 리스너 추가
|
||||
useEffect(() => {
|
||||
const handleFileUploaded = (event) => {
|
||||
const { jobNo } = event.detail;
|
||||
if (selectedProject && selectedProject.job_no === jobNo) {
|
||||
console.log('파일 업로드 감지됨, 자재 목록 갱신 중...');
|
||||
fetchMaterials();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('fileUploaded', handleFileUploaded);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('fileUploaded', handleFileUploaded);
|
||||
};
|
||||
}, [selectedProject]);
|
||||
|
||||
// 파일 목록 불러오기 (선택된 프로젝트가 바뀔 때마다)
|
||||
useEffect(() => {
|
||||
async function fetchFilesForProject() {
|
||||
if (!selectedProject?.job_no) {
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/files?job_no=${selectedProject.job_no}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (Array.isArray(data)) setFiles(data);
|
||||
else if (data && Array.isArray(data.files)) setFiles(data.files);
|
||||
else setFiles([]);
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
} catch {
|
||||
setFiles([]);
|
||||
}
|
||||
}
|
||||
fetchFilesForProject();
|
||||
}, [selectedProject]);
|
||||
|
||||
const fetchJobsData = async () => {
|
||||
try {
|
||||
const response = await fetchJobs({ project_id: selectedProject.id });
|
||||
if (response.data && response.data.jobs) {
|
||||
setJobs(response.data.jobs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Job 조회 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMaterials = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const skip = page * rowsPerPage;
|
||||
const params = {
|
||||
job_no: selectedJobNo || undefined,
|
||||
filename: selectedFilename || undefined,
|
||||
revision: selectedRevision || undefined,
|
||||
skip,
|
||||
limit: rowsPerPage,
|
||||
search: search || undefined,
|
||||
search_value: searchValue || undefined,
|
||||
item_type: itemType || undefined,
|
||||
material_grade: materialGrade || undefined,
|
||||
size_spec: sizeSpec || undefined,
|
||||
// file_id, fileFilter 등은 사용하지 않음
|
||||
grouping: groupingType || undefined,
|
||||
sort_by: sortBy || undefined
|
||||
};
|
||||
|
||||
// selectedProject가 없으면 API 호출하지 않음
|
||||
if (!selectedProject?.job_no) {
|
||||
setMaterials([]);
|
||||
setTotalCount(0);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('API 요청 파라미터:', params); // 디버깅용
|
||||
|
||||
const response = await fetchMaterialsApi(params);
|
||||
const data = response.data;
|
||||
|
||||
console.log('API 응답:', data); // 디버깅용
|
||||
|
||||
setMaterials(data.materials || []);
|
||||
setTotalCount(data.total_count || 0);
|
||||
|
||||
// 리비전 비교 데이터가 있으면 설정
|
||||
if (data.revision_comparison) {
|
||||
setRevisionComparison(data.revision_comparison);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('자재 조회 실패:', error);
|
||||
console.error('에러 상세:', error.response?.data); // 디버깅용
|
||||
setToast({
|
||||
open: true,
|
||||
message: `자재 데이터를 불러오는데 실패했습니다: ${error.response?.data?.detail || error.message}`,
|
||||
type: 'error'
|
||||
});
|
||||
setMaterials([]);
|
||||
setTotalCount(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePage = (event, newPage) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch('');
|
||||
setSearchValue('');
|
||||
setItemType('');
|
||||
setMaterialGrade('');
|
||||
setSizeSpec('');
|
||||
setFileFilter('');
|
||||
setSelectedJob('');
|
||||
setSelectedRevision('');
|
||||
setGroupingType('item');
|
||||
setSortBy('');
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const getItemTypeColor = (itemType) => {
|
||||
const colors = {
|
||||
'PIPE': 'primary',
|
||||
'FITTING': 'secondary',
|
||||
'VALVE': 'success',
|
||||
'FLANGE': 'warning',
|
||||
'BOLT': 'info',
|
||||
'GASKET': 'error',
|
||||
'INSTRUMENT': 'purple',
|
||||
'OTHER': 'default'
|
||||
};
|
||||
return colors[itemType] || 'default';
|
||||
};
|
||||
|
||||
const getRevisionChangeColor = (change) => {
|
||||
if (change > 0) return 'success';
|
||||
if (change < 0) return 'error';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const getRevisionChangeIcon = (change) => {
|
||||
if (change > 0) return <Add />;
|
||||
if (change < 0) return <Remove />;
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
📋 자재 목록
|
||||
</Typography>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Inventory sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
프로젝트를 선택해주세요
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
프로젝트 관리 탭에서 프로젝트를 선택하면 자재 목록을 확인할 수 있습니다.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
📋 자재 목록 (그룹핑)
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
|
||||
{selectedProject.project_name} ({selectedProject.official_project_code})
|
||||
</Typography>
|
||||
|
||||
{/* 필터/검색/정렬 UI */}
|
||||
<Box sx={{ mb: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2 }}>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
{/* 검색 유형 */}
|
||||
<Grid item xs={12} sm={3} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>검색 유형</InputLabel>
|
||||
<Select
|
||||
value={search}
|
||||
label="검색 유형"
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem key="all-search" value="">전체</MenuItem>
|
||||
<MenuItem key="project-search" value="project">프로젝트명</MenuItem>
|
||||
<MenuItem key="job-search" value="job">Job No.</MenuItem>
|
||||
<MenuItem key="material-search" value="material">자재명</MenuItem>
|
||||
<MenuItem key="description-search" value="description">설명</MenuItem>
|
||||
<MenuItem key="grade-search" value="grade">재질</MenuItem>
|
||||
<MenuItem key="size-search" value="size">사이즈</MenuItem>
|
||||
<MenuItem key="filename-search" value="filename">파일명</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 검색어 입력/선택 */}
|
||||
<Grid item xs={12} sm={3} md={2}>
|
||||
{search === 'project' ? (
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>프로젝트명 선택</InputLabel>
|
||||
<Select
|
||||
value={searchValue}
|
||||
label="프로젝트명 선택"
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem key="all-projects" value="">전체 프로젝트</MenuItem>
|
||||
<MenuItem key="mp7-rev2" value="MP7 PIPING PROJECT Rev.2">MP7 PIPING PROJECT Rev.2</MenuItem>
|
||||
<MenuItem key="pp5-5701" value="PP5 5701">PP5 5701</MenuItem>
|
||||
<MenuItem key="mp7" value="MP7">MP7</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : search === 'job' ? (
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Job No. 선택</InputLabel>
|
||||
<Select
|
||||
value={searchValue}
|
||||
label="Job No. 선택"
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem key="all-jobs-search" value="">전체 Job</MenuItem>
|
||||
{jobs.map((job) => (
|
||||
<MenuItem key={`job-search-${job.id}`} value={job.job_number}>
|
||||
{job.job_number}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : search === 'material' || search === 'description' ? (
|
||||
<TextField
|
||||
label="자재명/설명 검색"
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="자재명 또는 설명 입력"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton onClick={() => fetchMaterials()}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : search === 'grade' ? (
|
||||
<TextField
|
||||
label="재질 검색"
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="재질 입력 (예: SS316)"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton onClick={() => fetchMaterials()}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : search === 'size' ? (
|
||||
<TextField
|
||||
label="사이즈 검색"
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="사이즈 입력 (예: 6인치)"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton onClick={() => fetchMaterials()}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : search === 'filename' ? (
|
||||
<TextField
|
||||
label="파일명 검색"
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="파일명 입력"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton onClick={() => fetchMaterials()}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
label="검색어"
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="검색어 입력"
|
||||
disabled={!search}
|
||||
InputProps={{
|
||||
endAdornment: search && (
|
||||
<IconButton onClick={() => fetchMaterials()}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Job No. 선택 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Job No.</InputLabel>
|
||||
<Select
|
||||
value={selectedJobNo}
|
||||
label="Job No."
|
||||
onChange={e => setSelectedJobNo(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">전체</MenuItem>
|
||||
{jobs.map((job) => (
|
||||
<MenuItem key={job.job_number} value={job.job_number}>
|
||||
{job.job_number}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 도면명(파일명) 선택 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>도면명(파일명)</InputLabel>
|
||||
<Select
|
||||
value={selectedFilename}
|
||||
label="도면명(파일명)"
|
||||
onChange={e => setSelectedFilename(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">전체</MenuItem>
|
||||
{files.map((file) => (
|
||||
<MenuItem key={file.id} value={file.original_filename}>
|
||||
{file.bom_name || file.original_filename || file.filename}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 리비전 선택 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>리비전</InputLabel>
|
||||
<Select
|
||||
value={selectedRevision}
|
||||
label="리비전"
|
||||
onChange={e => setSelectedRevision(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">전체</MenuItem>
|
||||
{files
|
||||
.filter(file => file.original_filename === selectedFilename)
|
||||
.map((file) => (
|
||||
<MenuItem key={file.id} value={file.revision}>
|
||||
{file.revision}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 그룹핑 타입 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>그룹핑</InputLabel>
|
||||
<Select
|
||||
value={groupingType}
|
||||
label="그룹핑"
|
||||
onChange={e => setGroupingType(e.target.value)}
|
||||
>
|
||||
<MenuItem key="item" value="item">품목별</MenuItem>
|
||||
<MenuItem key="material" value="material">재질별</MenuItem>
|
||||
<MenuItem key="size" value="size">사이즈별</MenuItem>
|
||||
<MenuItem key="job" value="job">Job별</MenuItem>
|
||||
<MenuItem key="revision" value="revision">리비전별</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 품목 필터 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>품목</InputLabel>
|
||||
<Select
|
||||
value={itemType}
|
||||
label="품목"
|
||||
onChange={e => setItemType(e.target.value)}
|
||||
>
|
||||
<MenuItem key="all" value="">전체</MenuItem>
|
||||
<MenuItem key="PIPE" value="PIPE">PIPE</MenuItem>
|
||||
<MenuItem key="FITTING" value="FITTING">FITTING</MenuItem>
|
||||
<MenuItem key="VALVE" value="VALVE">VALVE</MenuItem>
|
||||
<MenuItem key="FLANGE" value="FLANGE">FLANGE</MenuItem>
|
||||
<MenuItem key="BOLT" value="BOLT">BOLT</MenuItem>
|
||||
<MenuItem key="GASKET" value="GASKET">GASKET</MenuItem>
|
||||
<MenuItem key="INSTRUMENT" value="INSTRUMENT">INSTRUMENT</MenuItem>
|
||||
<MenuItem key="OTHER" value="OTHER">기타</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 재질 필터 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<TextField
|
||||
label="재질"
|
||||
value={materialGrade}
|
||||
onChange={e => setMaterialGrade(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="예: SS316"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* 사이즈 필터 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<TextField
|
||||
label="사이즈"
|
||||
value={sizeSpec}
|
||||
onChange={e => setSizeSpec(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder={'예: 6"'}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* 파일명 필터 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<TextField
|
||||
label="파일명"
|
||||
value={fileFilter}
|
||||
onChange={e => setFileFilter(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="파일명 검색"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* 파일(도면) 선택 드롭다운 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>도면(파일)</InputLabel>
|
||||
<Select
|
||||
value={fileId}
|
||||
label="도면(파일)"
|
||||
onChange={e => setFileId(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">전체</MenuItem>
|
||||
{files.map((file) => (
|
||||
<MenuItem key={file.id} value={file.id}>
|
||||
{file.bom_name || file.original_filename || file.filename}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Grid item xs={6} sm={2} md={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>정렬</InputLabel>
|
||||
<Select
|
||||
value={sortBy}
|
||||
label="정렬"
|
||||
onChange={e => setSortBy(e.target.value)}
|
||||
>
|
||||
<MenuItem key="default" value="">기본</MenuItem>
|
||||
<MenuItem key="quantity_desc" value="quantity_desc">수량 내림차순</MenuItem>
|
||||
<MenuItem key="quantity_asc" value="quantity_asc">수량 오름차순</MenuItem>
|
||||
<MenuItem key="name_asc" value="name_asc">이름 오름차순</MenuItem>
|
||||
<MenuItem key="name_desc" value="name_desc">이름 내림차순</MenuItem>
|
||||
<MenuItem key="created_desc" value="created_desc">최신 업로드</MenuItem>
|
||||
<MenuItem key="created_asc" value="created_asc">오래된 업로드</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* 필터 초기화 */}
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<Clear />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
필터 초기화
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* 전역 Toast */}
|
||||
<Toast
|
||||
open={toast.open}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ open: false, message: '', type: 'info' })}
|
||||
/>
|
||||
|
||||
{/* 리비전 비교 알림 */}
|
||||
{revisionComparison && (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>리비전 비교:</strong> {revisionComparison.summary}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 필터 상태 표시 */}
|
||||
{(search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision || groupingType !== 'item' || sortBy) && (
|
||||
<Box sx={{ mb: 2, p: 1, bgcolor: 'info.50', borderRadius: 1, border: '1px solid', borderColor: 'info.200' }}>
|
||||
<Typography variant="body2" color="info.main">
|
||||
필터 적용 중:
|
||||
{search && ` 검색 유형: ${search}`}
|
||||
{searchValue && ` 검색어: "${searchValue}"`}
|
||||
{selectedJobNo && ` Job No: ${selectedJobNo}`}
|
||||
{selectedFilename && ` 파일: ${selectedFilename}`}
|
||||
{selectedRevision && ` 리비전: ${selectedRevision}`}
|
||||
{groupingType !== 'item' && ` 그룹핑: ${groupingType}`}
|
||||
{itemType && ` 품목: ${itemType}`}
|
||||
{materialGrade && ` 재질: ${materialGrade}`}
|
||||
{sizeSpec && ` 사이즈: ${sizeSpec}`}
|
||||
{fileFilter && ` 파일: ${fileFilter}`}
|
||||
{sortBy && ` 정렬: ${sortBy}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" sx={{ mt: 2 }}>
|
||||
자재 데이터 로딩 중...
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : materials.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Inventory sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision ? '검색 결과가 없습니다' : '자재 데이터가 없습니다'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
{search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision
|
||||
? '다른 검색 조건을 시도해보세요.'
|
||||
: '파일 업로드 탭에서 BOM 파일을 업로드해주세요.'
|
||||
}
|
||||
</Typography>
|
||||
{(search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision) && (
|
||||
<Button variant="outlined" onClick={clearFilters}>
|
||||
필터 초기화
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6">
|
||||
총 {totalCount.toLocaleString()}개 자재 그룹
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${materials.length}개 표시 중`}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||
<TableCell><strong>번호</strong></TableCell>
|
||||
<TableCell><strong>유형</strong></TableCell>
|
||||
<TableCell><strong>자재명</strong></TableCell>
|
||||
<TableCell align="center"><strong>총 수량</strong></TableCell>
|
||||
<TableCell align="center"><strong>단위</strong></TableCell>
|
||||
<TableCell align="center"><strong>사이즈</strong></TableCell>
|
||||
<TableCell><strong>재질</strong></TableCell>
|
||||
<TableCell align="center"><strong>Job No.</strong></TableCell>
|
||||
<TableCell align="center"><strong>리비전</strong></TableCell>
|
||||
<TableCell align="center"><strong>변경</strong></TableCell>
|
||||
<TableCell align="center"><strong>라인 수</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{materials.map((material, index) => (
|
||||
<TableRow
|
||||
key={`${material.id}-${index}`}
|
||||
sx={{ '&:hover': { bgcolor: 'grey.50' } }}
|
||||
>
|
||||
<TableCell>
|
||||
{page * rowsPerPage + index + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={material.item_type || 'OTHER'}
|
||||
size="small"
|
||||
color={getItemTypeColor(material.item_type)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ maxWidth: 300 }}>
|
||||
{material.original_description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Typography variant="h6" color="primary">
|
||||
{material.classified_category === 'PIPE' ? (() => {
|
||||
const bomLength = material.pipe_details?.total_length_mm || 0;
|
||||
const pipeCount = material.pipe_details?.pipe_count || 0;
|
||||
const cuttingLoss = pipeCount * 2;
|
||||
const requiredLength = bomLength + cuttingLoss;
|
||||
const pipesNeeded = Math.ceil(requiredLength / 6000);
|
||||
return pipesNeeded.toLocaleString();
|
||||
})() : material.quantity.toLocaleString()}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip label={material.classified_category === 'PIPE' ? '본' : material.unit} size="small" />
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={material.size_spec || '-'}
|
||||
size="small"
|
||||
color="secondary"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color="primary">
|
||||
{material.material_grade || '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={material.job_number || '-'}
|
||||
size="small"
|
||||
color="info"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={material.revision || 'Rev.0'}
|
||||
size="small"
|
||||
color="warning"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{material.quantity_change && (
|
||||
<Chip
|
||||
label={`${material.quantity_change > 0 ? '+' : ''}${material.quantity_change}`}
|
||||
size="small"
|
||||
color={getRevisionChangeColor(material.quantity_change)}
|
||||
icon={getRevisionChangeIcon(material.quantity_change)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={`${material.line_count || 1}개 라인`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
title={material.line_numbers_str}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
labelRowsPerPage="페이지당 행 수:"
|
||||
labelDisplayedRows={({ from, to, count }) =>
|
||||
`${from}-${to} / 총 ${count !== -1 ? count : to} 개`
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
MaterialList.propTypes = {
|
||||
selectedProject: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
project_name: PropTypes.string.isRequired,
|
||||
official_project_code: PropTypes.string.isRequired,
|
||||
}),
|
||||
};
|
||||
|
||||
export default MaterialList;
|
||||
559
tkeg/web/src/components/NavigationBar.css
Normal file
559
tkeg/web/src/components/NavigationBar.css
Normal file
@@ -0,0 +1,559 @@
|
||||
.navigation-bar {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
/* 브랜드 로고 */
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
font-size: 32px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-text span {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
display: block;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 모바일 메뉴 토글 */
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle span {
|
||||
width: 24px;
|
||||
height: 3px;
|
||||
background: white;
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 메인 메뉴 */
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menu-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.menu-item.active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.menu-item.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20px;
|
||||
height: 3px;
|
||||
background: white;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* 사용자 메뉴 */
|
||||
.user-menu-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-menu-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.user-menu-trigger:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 11px;
|
||||
opacity: 0.9;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
font-size: 10px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.user-menu-trigger:hover .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 사용자 드롭다운 */
|
||||
.user-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 320px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
animation: dropdownSlide 0.3s ease-out;
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
@keyframes dropdownSlide {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.user-dropdown-header {
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.user-avatar-large {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-details .user-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #2d3748;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-username {
|
||||
font-size: 14px;
|
||||
color: #718096;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 13px;
|
||||
color: #4a5568;
|
||||
margin-bottom: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.role-badge,
|
||||
.access-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.role-badge { background: #bee3f8; color: #2b6cb0; }
|
||||
.access-badge { background: #c6f6d5; color: #2f855a; }
|
||||
|
||||
.user-department {
|
||||
font-size: 12px;
|
||||
color: #718096;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.user-dropdown-menu {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: #f7fafc;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.dropdown-item.logout-item {
|
||||
color: #e53e3e;
|
||||
}
|
||||
|
||||
.dropdown-item.logout-item:hover {
|
||||
background: #fed7d7;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background: #e2e8f0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.user-dropdown-footer {
|
||||
padding: 16px 24px;
|
||||
background: #f7fafc;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.permissions-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.permissions-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #718096;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.permissions-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.permission-tag {
|
||||
padding: 2px 6px;
|
||||
background: #e2e8f0;
|
||||
color: #4a5568;
|
||||
font-size: 10px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.permission-more {
|
||||
padding: 2px 6px;
|
||||
background: #cbd5e0;
|
||||
color: #2d3748;
|
||||
font-size: 10px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 모바일 오버레이 */
|
||||
.mobile-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 1024px) {
|
||||
.menu-items {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nav-container {
|
||||
padding: 0 16px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.brand-text span {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-menu.mobile-open {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.mobile-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-items {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
padding: 16px;
|
||||
color: #2d3748;
|
||||
border-radius: 12px;
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: #edf2f7;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.menu-item.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.user-menu-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-menu-trigger {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
background: #f7fafc;
|
||||
color: #2d3748;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
position: static;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
box-shadow: none;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.nav-container {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
width: calc(100vw - 24px);
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
292
tkeg/web/src/components/NavigationBar.jsx
Normal file
292
tkeg/web/src/components/NavigationBar.jsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import './NavigationBar.css';
|
||||
|
||||
const NavigationBar = ({ currentPage, onNavigate }) => {
|
||||
const { user, logout, hasPermission, isAdmin, isManager } = useAuth();
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
// 메뉴 항목 정의 (권한별)
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: '대시보드',
|
||||
icon: '📊',
|
||||
path: '/dashboard',
|
||||
permission: null, // 모든 사용자 접근 가능
|
||||
description: '전체 현황 보기'
|
||||
},
|
||||
{
|
||||
id: 'projects',
|
||||
label: '프로젝트 관리',
|
||||
icon: '📋',
|
||||
path: '/projects',
|
||||
permission: 'project.view',
|
||||
description: '프로젝트 등록 및 관리'
|
||||
},
|
||||
{
|
||||
id: 'bom',
|
||||
label: 'BOM 관리',
|
||||
icon: '📄',
|
||||
path: '/bom',
|
||||
permission: 'bom.view',
|
||||
description: 'BOM 파일 업로드 및 분석'
|
||||
},
|
||||
{
|
||||
id: 'materials',
|
||||
label: '자재 관리',
|
||||
icon: '🔧',
|
||||
path: '/materials',
|
||||
permission: 'bom.view',
|
||||
description: '자재 목록 및 비교'
|
||||
},
|
||||
{
|
||||
id: 'purchase',
|
||||
label: '구매 관리',
|
||||
icon: '💰',
|
||||
path: '/purchase',
|
||||
permission: 'project.view',
|
||||
description: '구매 확인 및 관리'
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
label: '파일 관리',
|
||||
icon: '📁',
|
||||
path: '/files',
|
||||
permission: 'file.upload',
|
||||
description: '파일 업로드 및 관리'
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: '사용자 관리',
|
||||
icon: '👥',
|
||||
path: '/users',
|
||||
permission: 'user.view',
|
||||
description: '사용자 계정 관리',
|
||||
adminOnly: true
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
label: '시스템 설정',
|
||||
icon: '⚙️',
|
||||
path: '/system',
|
||||
permission: 'system.admin',
|
||||
description: '시스템 환경 설정',
|
||||
adminOnly: true
|
||||
}
|
||||
];
|
||||
|
||||
// 사용자가 접근 가능한 메뉴만 필터링
|
||||
const accessibleMenuItems = menuItems.filter(item => {
|
||||
// 관리자 전용 메뉴 체크
|
||||
if (item.adminOnly && !isAdmin() && !isManager()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 권한 체크
|
||||
if (item.permission && !hasPermission(item.permission)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
setShowUserMenu(false);
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuClick = (item) => {
|
||||
onNavigate(item.id);
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const getRoleDisplayName = (role) => {
|
||||
const roleMap = {
|
||||
'admin': '관리자',
|
||||
'system': '시스템',
|
||||
'leader': '팀장',
|
||||
'support': '지원',
|
||||
'user': '사용자'
|
||||
};
|
||||
return roleMap[role] || role;
|
||||
};
|
||||
|
||||
const getAccessLevelDisplayName = (level) => {
|
||||
const levelMap = {
|
||||
'manager': '관리자',
|
||||
'leader': '팀장',
|
||||
'worker': '작업자',
|
||||
'viewer': '조회자'
|
||||
};
|
||||
return levelMap[level] || level;
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="navigation-bar">
|
||||
<div className="nav-container">
|
||||
{/* 로고 및 브랜드 */}
|
||||
<div className="nav-brand">
|
||||
<div className="brand-logo">🚀</div>
|
||||
<div className="brand-text">
|
||||
<h1>TK-MP System</h1>
|
||||
<span>통합 프로젝트 관리</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일 메뉴 토글 */}
|
||||
<button
|
||||
className="mobile-menu-toggle"
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
|
||||
{/* 메인 메뉴 */}
|
||||
<div className={`nav-menu ${isMobileMenuOpen ? 'mobile-open' : ''}`}>
|
||||
<div className="menu-items">
|
||||
{accessibleMenuItems.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`menu-item ${currentPage === item.id ? 'active' : ''}`}
|
||||
onClick={() => handleMenuClick(item)}
|
||||
title={item.description}
|
||||
>
|
||||
<span className="menu-icon">{item.icon}</span>
|
||||
<span className="menu-label">{item.label}</span>
|
||||
{item.adminOnly && (
|
||||
<span className="admin-badge">관리자</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 사용자 메뉴 */}
|
||||
<div className="user-menu-container">
|
||||
<button
|
||||
className="user-menu-trigger"
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
>
|
||||
<div className="user-avatar">
|
||||
{user?.name?.charAt(0) || '👤'}
|
||||
</div>
|
||||
<div className="user-info">
|
||||
<span className="user-name">{user?.name}</span>
|
||||
<span className="user-role">
|
||||
{getRoleDisplayName(user?.role)} · {getAccessLevelDisplayName(user?.access_level)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="dropdown-arrow">▼</span>
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div className="user-dropdown">
|
||||
<div className="user-dropdown-header">
|
||||
<div className="user-avatar-large">
|
||||
{user?.name?.charAt(0) || '👤'}
|
||||
</div>
|
||||
<div className="user-details">
|
||||
<div className="user-name">{user?.name}</div>
|
||||
<div className="user-username">@{user?.username}</div>
|
||||
<div className="user-email">{user?.email}</div>
|
||||
<div className="user-meta">
|
||||
<span className="role-badge role-{user?.role}">
|
||||
{getRoleDisplayName(user?.role)}
|
||||
</span>
|
||||
<span className="access-badge access-{user?.access_level}">
|
||||
{getAccessLevelDisplayName(user?.access_level)}
|
||||
</span>
|
||||
</div>
|
||||
{user?.department && (
|
||||
<div className="user-department">{user.department}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-dropdown-menu">
|
||||
<button className="dropdown-item">
|
||||
<span className="item-icon">👤</span>
|
||||
프로필 설정
|
||||
</button>
|
||||
<button className="dropdown-item">
|
||||
<span className="item-icon">🔐</span>
|
||||
비밀번호 변경
|
||||
</button>
|
||||
<button className="dropdown-item">
|
||||
<span className="item-icon">🔔</span>
|
||||
알림 설정
|
||||
</button>
|
||||
<div className="dropdown-divider"></div>
|
||||
<button
|
||||
className="dropdown-item logout-item"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<span className="item-icon">🚪</span>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="user-dropdown-footer">
|
||||
<div className="permissions-info">
|
||||
<span className="permissions-label">권한:</span>
|
||||
<div className="permissions-list">
|
||||
{user?.permissions?.slice(0, 3).map(permission => (
|
||||
<span key={permission} className="permission-tag">
|
||||
{permission}
|
||||
</span>
|
||||
))}
|
||||
{user?.permissions?.length > 3 && (
|
||||
<span className="permission-more">
|
||||
+{user.permissions.length - 3}개 더
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일 오버레이 */}
|
||||
{isMobileMenuOpen && (
|
||||
<div
|
||||
className="mobile-overlay"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationBar;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
272
tkeg/web/src/components/NavigationMenu.css
Normal file
272
tkeg/web/src/components/NavigationMenu.css
Normal file
@@ -0,0 +1,272 @@
|
||||
/* 네비게이션 메뉴 스타일 */
|
||||
.navigation-menu {
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* 모바일 햄버거 버튼 */
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle span {
|
||||
width: 24px;
|
||||
height: 3px;
|
||||
background: #4a5568;
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 메뉴 오버레이 (모바일) */
|
||||
.menu-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
/* 사이드바 */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* 사이드바 헤더 */
|
||||
.sidebar-header {
|
||||
padding: 24px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.logo-text h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.logo-text span {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 메뉴 섹션 */
|
||||
.menu-section {
|
||||
flex: 1;
|
||||
padding: 20px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.menu-section-title {
|
||||
padding: 0 20px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #718096;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.menu-button:hover {
|
||||
background: #f7fafc;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.menu-button.active {
|
||||
background: #edf2f7;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-button.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: #667eea;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 18px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.active-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #667eea;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* 사이드바 푸터 */
|
||||
.sidebar-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 12px;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.mobile-menu-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.menu-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 데스크톱에서 사이드바가 있을 때 메인 콘텐츠 여백 */
|
||||
@media (min-width: 769px) {
|
||||
.main-content-with-sidebar {
|
||||
margin-left: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 스크롤바 스타일링 */
|
||||
.menu-section::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.menu-section::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.menu-section::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.menu-section::-webkit-scrollbar-thumb:hover {
|
||||
background: #a1a1a1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
196
tkeg/web/src/components/NavigationMenu.jsx
Normal file
196
tkeg/web/src/components/NavigationMenu.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React, { useState } from 'react';
|
||||
import './NavigationMenu.css';
|
||||
|
||||
const NavigationMenu = ({ user, currentPage, onPageChange }) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
// 권한별 메뉴 정의
|
||||
const getMenuItems = () => {
|
||||
const baseMenus = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
title: '대시보드',
|
||||
icon: '🏠',
|
||||
description: '시스템 현황 및 개요',
|
||||
requiredPermission: null // 모든 사용자
|
||||
}
|
||||
];
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'projects',
|
||||
title: '프로젝트 관리',
|
||||
icon: '📋',
|
||||
description: '프로젝트 등록 및 관리',
|
||||
requiredPermission: 'project_management'
|
||||
},
|
||||
{
|
||||
id: 'bom',
|
||||
title: 'BOM 관리',
|
||||
icon: '🔧',
|
||||
description: 'Bill of Materials 관리',
|
||||
requiredPermission: 'bom_management'
|
||||
},
|
||||
{
|
||||
id: 'materials',
|
||||
title: '자재 관리',
|
||||
icon: '📦',
|
||||
description: '자재 정보 및 재고 관리',
|
||||
requiredPermission: 'material_management'
|
||||
},
|
||||
{
|
||||
id: 'quotes',
|
||||
title: '견적 관리',
|
||||
icon: '💰',
|
||||
description: '견적서 작성 및 관리',
|
||||
requiredPermission: 'quote_management'
|
||||
},
|
||||
{
|
||||
id: 'procurement',
|
||||
title: '구매 관리',
|
||||
icon: '🛒',
|
||||
description: '구매 요청 및 발주 관리',
|
||||
requiredPermission: 'procurement_management'
|
||||
},
|
||||
{
|
||||
id: 'production',
|
||||
title: '생산 관리',
|
||||
icon: '🏭',
|
||||
description: '생산 계획 및 진행 관리',
|
||||
requiredPermission: 'production_management'
|
||||
},
|
||||
{
|
||||
id: 'shipment',
|
||||
title: '출하 관리',
|
||||
icon: '🚚',
|
||||
description: '출하 계획 및 배송 관리',
|
||||
requiredPermission: 'shipment_management'
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
title: '사용자 관리',
|
||||
icon: '👥',
|
||||
description: '사용자 계정 및 권한 관리',
|
||||
requiredPermission: 'user_management'
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
title: '시스템 설정',
|
||||
icon: '⚙️',
|
||||
description: '시스템 환경 설정',
|
||||
requiredPermission: 'system_admin'
|
||||
}
|
||||
];
|
||||
|
||||
// 사용자 권한에 따라 메뉴 필터링
|
||||
const userPermissions = user?.permissions || [];
|
||||
const filteredMenus = menuItems.filter(menu =>
|
||||
!menu.requiredPermission ||
|
||||
userPermissions.includes(menu.requiredPermission) ||
|
||||
user?.role === 'admin' // 관리자는 모든 메뉴 접근 가능
|
||||
);
|
||||
|
||||
return [...baseMenus, ...filteredMenus];
|
||||
};
|
||||
|
||||
const menuItems = getMenuItems();
|
||||
|
||||
const handleMenuClick = (menuId) => {
|
||||
onPageChange(menuId);
|
||||
setIsMenuOpen(false); // 모바일에서 메뉴 닫기
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="navigation-menu">
|
||||
{/* 모바일 햄버거 버튼 */}
|
||||
<button
|
||||
className="mobile-menu-toggle"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
aria-label="메뉴 토글"
|
||||
>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
|
||||
{/* 메뉴 오버레이 (모바일) */}
|
||||
{isMenuOpen && (
|
||||
<div
|
||||
className="menu-overlay"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 사이드바 메뉴 */}
|
||||
<nav className={`sidebar ${isMenuOpen ? 'open' : ''}`}>
|
||||
<div className="sidebar-header">
|
||||
<div className="logo">
|
||||
<span className="logo-icon">🚀</span>
|
||||
<div className="logo-text">
|
||||
<h2>TK-MP</h2>
|
||||
<span>통합 관리 시스템</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="menu-section">
|
||||
<div className="menu-section-title">메인 메뉴</div>
|
||||
<ul className="menu-list">
|
||||
{menuItems.map(item => (
|
||||
<li key={item.id} className="menu-item">
|
||||
<button
|
||||
className={`menu-button ${currentPage === item.id ? 'active' : ''}`}
|
||||
onClick={() => handleMenuClick(item.id)}
|
||||
title={item.description}
|
||||
>
|
||||
<span className="menu-icon">{item.icon}</span>
|
||||
<span className="menu-title">{item.title}</span>
|
||||
{currentPage === item.id && (
|
||||
<span className="active-indicator"></span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 사용자 정보 */}
|
||||
<div className="sidebar-footer">
|
||||
<div className="user-info">
|
||||
<div className="user-avatar">
|
||||
{user?.name?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div className="user-details">
|
||||
<div className="user-name">{user?.name}</div>
|
||||
<div className="user-role">{user?.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationMenu;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
482
tkeg/web/src/components/PersonalizedDashboard.jsx
Normal file
482
tkeg/web/src/components/PersonalizedDashboard.jsx
Normal file
@@ -0,0 +1,482 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
const PersonalizedDashboard = () => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [dashboardData, setDashboardData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [recentActivities, setRecentActivities] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUserData();
|
||||
loadDashboardData();
|
||||
loadRecentActivities();
|
||||
}, []);
|
||||
|
||||
const loadUserData = () => {
|
||||
const userData = localStorage.getItem('user_data');
|
||||
if (userData) {
|
||||
setUser(JSON.parse(userData));
|
||||
}
|
||||
};
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
// 실제 API에서 대시보드 데이터 로드
|
||||
const response = await api.get('/dashboard/stats');
|
||||
if (response.data && response.data.success) {
|
||||
// API 데이터와 목 데이터를 병합 (quickActions 등 누락된 필드 보완)
|
||||
const mockData = generateMockDataByRole();
|
||||
const mergedData = {
|
||||
...mockData,
|
||||
...response.data.stats,
|
||||
// quickActions가 없으면 목 데이터의 것을 사용
|
||||
quickActions: response.data.stats.quickActions || mockData?.quickActions || []
|
||||
};
|
||||
setDashboardData(mergedData);
|
||||
} else {
|
||||
// API 실패 시 목 데이터 사용
|
||||
console.log('대시보드 API 응답이 없어 목 데이터를 사용합니다.');
|
||||
const mockData = generateMockDataByRole();
|
||||
setDashboardData(mockData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('대시보드 API가 구현되지 않아 목 데이터를 사용합니다:', error.response?.status);
|
||||
// 에러 시 목 데이터 사용
|
||||
const mockData = generateMockDataByRole();
|
||||
setDashboardData(mockData);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRecentActivities = async () => {
|
||||
try {
|
||||
// 실제 API에서 활동 이력 로드
|
||||
const response = await api.get('/dashboard/activities?limit=5');
|
||||
if (response.data.success && response.data.activities.length > 0) {
|
||||
setRecentActivities(response.data.activities);
|
||||
} else {
|
||||
// API 실패 시 목 데이터 사용
|
||||
const mockActivities = generateMockActivitiesByRole();
|
||||
setRecentActivities(mockActivities);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('활동 이력 API가 구현되지 않아 목 데이터를 사용합니다:', error.response?.status);
|
||||
// 에러 시 목 데이터 사용
|
||||
const mockActivities = generateMockActivitiesByRole();
|
||||
setRecentActivities(mockActivities);
|
||||
}
|
||||
};
|
||||
|
||||
const generateMockDataByRole = () => {
|
||||
if (!user) return null;
|
||||
|
||||
const baseData = {
|
||||
admin: {
|
||||
title: "시스템 관리자",
|
||||
subtitle: "전체 시스템을 관리하고 모니터링합니다",
|
||||
metrics: [
|
||||
{ label: "전체 프로젝트 수", value: 45, icon: "📋", color: "#667eea" },
|
||||
{ label: "활성 사용자 수", value: 12, icon: "👥", color: "#48bb78" },
|
||||
{ label: "시스템 상태", value: "정상", icon: "🟢", color: "#38b2ac" },
|
||||
{ label: "오늘 업로드", value: 8, icon: "📤", color: "#ed8936" }
|
||||
],
|
||||
quickActions: [
|
||||
{ title: "사용자 관리", icon: "👤", path: "/admin/users", color: "#667eea" },
|
||||
{ title: "시스템 설정", icon: "⚙️", path: "/admin/settings", color: "#48bb78" },
|
||||
{ title: "백업 관리", icon: "💾", path: "/admin/backup", color: "#ed8936" },
|
||||
{ title: "활동 로그", icon: "📊", path: "/admin/logs", color: "#9f7aea" }
|
||||
]
|
||||
},
|
||||
manager: {
|
||||
title: "프로젝트 매니저",
|
||||
subtitle: "팀 프로젝트를 관리하고 진행상황을 모니터링합니다",
|
||||
metrics: [
|
||||
{ label: "담당 프로젝트", value: 8, icon: "📋", color: "#667eea" },
|
||||
{ label: "팀 진행률", value: "87%", icon: "📈", color: "#48bb78" },
|
||||
{ label: "승인 대기", value: 3, icon: "⏳", color: "#ed8936" },
|
||||
{ label: "이번 주 완료", value: 5, icon: "✅", color: "#38b2ac" }
|
||||
],
|
||||
quickActions: [
|
||||
{ title: "프로젝트 생성", icon: "➕", path: "/projects/new", color: "#667eea" },
|
||||
{ title: "팀 관리", icon: "👥", path: "/team", color: "#48bb78" },
|
||||
{ title: "진행 상황", icon: "📊", path: "/progress", color: "#38b2ac" },
|
||||
{ title: "승인 처리", icon: "✅", path: "/approvals", color: "#ed8936" }
|
||||
]
|
||||
},
|
||||
designer: {
|
||||
title: "설계 담당자",
|
||||
subtitle: "BOM 파일을 관리하고 자재를 분류합니다",
|
||||
metrics: [
|
||||
{ label: "내 BOM 파일", value: 15, icon: "📄", color: "#667eea" },
|
||||
{ label: "분류 완료율", value: "92%", icon: "🎯", color: "#48bb78" },
|
||||
{ label: "검증 대기", value: 7, icon: "⏳", color: "#ed8936" },
|
||||
{ label: "이번 주 업로드", value: 12, icon: "📤", color: "#9f7aea" }
|
||||
],
|
||||
quickActions: [
|
||||
{ title: "BOM 업로드", icon: "📤", path: "/upload", color: "#667eea" },
|
||||
{ title: "자재 분류", icon: "🔧", path: "/materials", color: "#48bb78" },
|
||||
{ title: "리비전 관리", icon: "🔄", path: "/revisions", color: "#38b2ac" },
|
||||
{ title: "분류 검증", icon: "✅", path: "/verify", color: "#ed8936" }
|
||||
]
|
||||
},
|
||||
purchaser: {
|
||||
title: "구매 담당자",
|
||||
subtitle: "구매 요청을 처리하고 발주를 관리합니다",
|
||||
metrics: [
|
||||
{ label: "구매 요청", value: 23, icon: "🛒", color: "#667eea" },
|
||||
{ label: "발주 완료", value: 18, icon: "✅", color: "#48bb78" },
|
||||
{ label: "입고 대기", value: 5, icon: "📦", color: "#ed8936" },
|
||||
{ label: "이번 달 금액", value: "₩2.3M", icon: "💰", color: "#9f7aea" }
|
||||
],
|
||||
quickActions: [
|
||||
{ title: "구매 확정", icon: "🛒", path: "/purchase", color: "#667eea" },
|
||||
{ title: "발주 관리", icon: "📋", path: "/orders", color: "#48bb78" },
|
||||
{ title: "공급업체", icon: "🏢", path: "/suppliers", color: "#38b2ac" },
|
||||
{ title: "입고 처리", icon: "📦", path: "/receiving", color: "#ed8936" }
|
||||
]
|
||||
},
|
||||
user: {
|
||||
title: "일반 사용자",
|
||||
subtitle: "할당된 업무를 수행하고 프로젝트에 참여합니다",
|
||||
metrics: [
|
||||
{ label: "내 업무", value: 6, icon: "📋", color: "#667eea" },
|
||||
{ label: "완료율", value: "75%", icon: "📈", color: "#48bb78" },
|
||||
{ label: "대기 중", value: 2, icon: "⏳", color: "#ed8936" },
|
||||
{ label: "이번 주 활동", value: 12, icon: "🎯", color: "#9f7aea" }
|
||||
],
|
||||
quickActions: [
|
||||
{ title: "내 업무", icon: "📋", path: "/my-tasks", color: "#667eea" },
|
||||
{ title: "프로젝트 보기", icon: "👁️", path: "/projects", color: "#48bb78" },
|
||||
{ title: "리포트 다운로드", icon: "📊", path: "/reports", color: "#38b2ac" },
|
||||
{ title: "도움말", icon: "❓", path: "/help", color: "#9f7aea" }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return baseData[user.role] || baseData.user;
|
||||
};
|
||||
|
||||
const generateMockActivitiesByRole = () => {
|
||||
if (!user) return [];
|
||||
|
||||
const activities = {
|
||||
admin: [
|
||||
{ type: "system", message: "새 사용자 3명이 등록되었습니다", time: "30분 전", icon: "👥" },
|
||||
{ type: "backup", message: "일일 백업이 완료되었습니다", time: "2시간 전", icon: "💾" },
|
||||
{ type: "alert", message: "시스템 리소스 사용률 85%", time: "4시간 전", icon: "⚠️" },
|
||||
{ type: "update", message: "데이터베이스 인덱스가 최적화되었습니다", time: "6시간 전", icon: "🔧" }
|
||||
],
|
||||
manager: [
|
||||
{ type: "approval", message: "냉동기 프로젝트 구매 승인 완료", time: "1시간 전", icon: "✅" },
|
||||
{ type: "meeting", message: "주간 팀 미팅 일정이 등록되었습니다", time: "3시간 전", icon: "📅" },
|
||||
{ type: "progress", message: "BOG 시스템 프로젝트 90% 진행", time: "5시간 전", icon: "📈" },
|
||||
{ type: "task", message: "김설계님에게 새 업무가 할당되었습니다", time: "1일 전", icon: "👤" }
|
||||
],
|
||||
designer: [
|
||||
{ type: "upload", message: "다이아프램 펌프 BOM 파일을 업로드했습니다", time: "45분 전", icon: "📤" },
|
||||
{ type: "classify", message: "스테인리스 파이프 127개 자재 분류 완료", time: "2시간 전", icon: "🔧" },
|
||||
{ type: "revision", message: "드라이어 시스템 Rev.2 업데이트", time: "4시간 전", icon: "🔄" },
|
||||
{ type: "verify", message: "볼트 분류 검증 5건 완료", time: "1일 전", icon: "✅" }
|
||||
],
|
||||
purchaser: [
|
||||
{ type: "purchase", message: "스테인리스 파이프 구매 확정", time: "20분 전", icon: "🛒" },
|
||||
{ type: "order", message: "ABC 공급업체에 발주서 전송", time: "1시간 전", icon: "📋" },
|
||||
{ type: "receive", message: "밸브 15개 입고 처리 완료", time: "3시간 전", icon: "📦" },
|
||||
{ type: "quote", message: "새 견적서 3건 접수", time: "5시간 전", icon: "💰" }
|
||||
],
|
||||
user: [
|
||||
{ type: "task", message: "자재 검증 업무 2건 완료", time: "1시간 전", icon: "✅" },
|
||||
{ type: "view", message: "냉동기 프로젝트 진행상황 확인", time: "3시간 전", icon: "👁️" },
|
||||
{ type: "download", message: "월간 리포트 다운로드", time: "6시간 전", icon: "📊" },
|
||||
{ type: "help", message: "도움말 페이지 방문", time: "1일 전", icon: "❓" }
|
||||
]
|
||||
};
|
||||
|
||||
return activities[user.role] || activities.user;
|
||||
};
|
||||
|
||||
const handleQuickAction = (action) => {
|
||||
// 실제 네비게이션 구현 (향후)
|
||||
console.log(`네비게이션: ${action.path}`);
|
||||
alert(`${action.title} 기능은 곧 구현될 예정입니다.`);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('user_data');
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
if (loading || !user || !dashboardData) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f7fafc'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>⏳</div>
|
||||
<div>대시보드를 불러오는 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: '#f7fafc',
|
||||
fontFamily: 'Arial, sans-serif'
|
||||
}}>
|
||||
{/* 네비게이션 바 */}
|
||||
<nav style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: '16px 24px',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{ fontSize: '24px' }}>🚀</span>
|
||||
<div>
|
||||
<h1 style={{ margin: '0', fontSize: '20px', fontWeight: '700' }}>TK-MP System</h1>
|
||||
<span style={{ fontSize: '12px', opacity: '0.9' }}>통합 프로젝트 관리</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600' }}>{user.name || user.username}</div>
|
||||
<div style={{ fontSize: '12px', opacity: '0.9' }}>
|
||||
{dashboardData.title}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<main style={{ padding: '32px 24px' }}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 개인별 맞춤 배너 */}
|
||||
<div style={{
|
||||
background: `linear-gradient(135deg, ${dashboardData.metrics[0].color}20 0%, ${dashboardData.metrics[1].color}20 100%)`,
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
marginBottom: '32px',
|
||||
border: `1px solid ${dashboardData.metrics[0].color}40`
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '48px' }}>
|
||||
{user.role === 'admin' ? '👑' :
|
||||
user.role === 'manager' ? '👨💼' :
|
||||
user.role === 'designer' ? '🎨' :
|
||||
user.role === 'purchaser' ? '🛒' : '👤'}
|
||||
</div>
|
||||
<div>
|
||||
<h2 style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
안녕하세요, {user.name || user.username}님! 👋
|
||||
</h2>
|
||||
<p style={{
|
||||
margin: '0',
|
||||
fontSize: '16px',
|
||||
color: '#4a5568',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{dashboardData.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 핵심 지표 카드들 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
|
||||
gap: '24px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
{(dashboardData.metrics || []).map((metric, index) => (
|
||||
<div key={index} style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e2e8f0',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.15)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)';
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096',
|
||||
marginBottom: '8px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{metric.label}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: '700',
|
||||
color: metric.color
|
||||
}}>
|
||||
{metric.value}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '32px',
|
||||
opacity: 0.8
|
||||
}}>
|
||||
{metric.icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
|
||||
gap: '24px'
|
||||
}}>
|
||||
{/* 빠른 작업 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
⚡ 빠른 작업
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
{(dashboardData.quickActions || []).map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleQuickAction(action)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px 16px',
|
||||
background: 'transparent',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '14px',
|
||||
color: '#4a5568',
|
||||
textAlign: 'left'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = `${action.color}10`;
|
||||
e.target.style.borderColor = action.color;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = 'transparent';
|
||||
e.target.style.borderColor = '#e2e8f0';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>{action.icon}</span>
|
||||
<span>{action.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 최근 활동 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
📈 최근 활동
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{recentActivities.map((activity, index) => (
|
||||
<div key={index} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '12px',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
background: '#f7fafc',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<span style={{ fontSize: '16px' }}>
|
||||
{activity.icon}
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#2d3748',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{activity.message}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
{activity.time}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonalizedDashboard;
|
||||
99
tkeg/web/src/components/PipeDetailsCard.jsx
Normal file
99
tkeg/web/src/components/PipeDetailsCard.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Box, Grid, Chip, Divider } from '@mui/material';
|
||||
|
||||
const PipeDetailsCard = ({ material }) => {
|
||||
const pipeDetails = material.pipe_details || {};
|
||||
|
||||
return (
|
||||
<Card sx={{ mt: 2, backgroundColor: '#f5f5f5' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
🔧 PIPE 상세 정보
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`신뢰도: ${Math.round((material.classification_confidence || 0) * 100)}%`}
|
||||
color={material.classification_confidence >= 0.8 ? 'success' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" color="text.secondary">자재명</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 'medium' }}>
|
||||
{material.original_description}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">크기</Typography>
|
||||
<Typography variant="body1">
|
||||
{pipeDetails.size_inches || material.size_spec || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">스케줄</Typography>
|
||||
<Typography variant="body1">
|
||||
{pipeDetails.schedule_type || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">재질</Typography>
|
||||
<Typography variant="body1">
|
||||
{pipeDetails.material_spec || material.material_grade || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">제작방식</Typography>
|
||||
<Typography variant="body1">
|
||||
{pipeDetails.manufacturing_method || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">길이</Typography>
|
||||
<Typography variant="body1">
|
||||
{pipeDetails.length_mm ? `${pipeDetails.length_mm}mm` : '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">외경</Typography>
|
||||
<Typography variant="body1">
|
||||
{pipeDetails.outer_diameter_mm ? `${pipeDetails.outer_diameter_mm}mm` : '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">두께</Typography>
|
||||
<Typography variant="body1">
|
||||
{pipeDetails.wall_thickness_mm ? `${pipeDetails.wall_thickness_mm}mm` : '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="subtitle2" color="text.secondary">중량</Typography>
|
||||
<Typography variant="body1">
|
||||
{pipeDetails.weight_per_meter_kg ? `${pipeDetails.weight_per_meter_kg}kg/m` : '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" color="text.secondary">수량</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
|
||||
{material.quantity} {material.unit}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PipeDetailsCard;
|
||||
518
tkeg/web/src/components/ProjectManager.jsx
Normal file
518
tkeg/web/src/components/ProjectManager.jsx
Normal file
@@ -0,0 +1,518 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Snackbar,
|
||||
IconButton,
|
||||
Chip,
|
||||
Grid,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
Divider,
|
||||
Menu,
|
||||
MenuItem
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
Assignment,
|
||||
Edit,
|
||||
Delete,
|
||||
MoreVert,
|
||||
Visibility,
|
||||
CheckCircle,
|
||||
Warning
|
||||
} from '@mui/icons-material';
|
||||
import { createJob, updateProject, deleteProject } from '../api';
|
||||
import Toast from './Toast';
|
||||
|
||||
function ProjectManager({ projects, selectedProject, setSelectedProject, onProjectsChange }) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState(null);
|
||||
const [projectCode, setProjectCode] = useState('');
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
|
||||
const [menuAnchor, setMenuAnchor] = useState(null);
|
||||
const [selectedProjectForMenu, setSelectedProjectForMenu] = useState(null);
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
if (!projectCode.trim() || !projectName.trim()) {
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트 코드와 이름을 모두 입력해주세요.',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const data = {
|
||||
official_project_code: projectCode.trim(),
|
||||
project_name: projectName.trim(),
|
||||
design_project_code: projectCode.trim(),
|
||||
is_code_matched: true,
|
||||
status: 'active'
|
||||
};
|
||||
const response = await createJob(data);
|
||||
const result = response.data;
|
||||
if (result && result.job) {
|
||||
onProjectsChange();
|
||||
setSelectedProject(result.job);
|
||||
setDialogOpen(false);
|
||||
setProjectCode('');
|
||||
setProjectName('');
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트가 성공적으로 생성되었습니다.',
|
||||
type: 'success'
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
open: true,
|
||||
message: result.message || '프로젝트 생성에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 생성 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '네트워크 오류가 발생했습니다. 백엔드 서버가 실행 중인지 확인해주세요.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditProject = async () => {
|
||||
if (!editingProject || !editingProject.project_name.trim()) {
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트명을 입력해주세요.',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await updateProject(editingProject.id, {
|
||||
project_name: editingProject.project_name.trim(),
|
||||
status: editingProject.status
|
||||
});
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
onProjectsChange();
|
||||
setEditDialogOpen(false);
|
||||
setEditingProject(null);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트가 성공적으로 수정되었습니다.',
|
||||
type: 'success'
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
open: true,
|
||||
message: response.data?.message || '프로젝트 수정에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 수정 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트 수정 중 오류가 발생했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProject = async (project) => {
|
||||
if (!window.confirm(`정말로 프로젝트 "${project.project_name}"을 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await deleteProject(project.id);
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
onProjectsChange();
|
||||
if (selectedProject?.id === project.id) {
|
||||
setSelectedProject(null);
|
||||
}
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트가 성공적으로 삭제되었습니다.',
|
||||
type: 'success'
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
open: true,
|
||||
message: response.data?.message || '프로젝트 삭제에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 삭제 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '프로젝트 삭제 중 오류가 발생했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenMenu = (event, project) => {
|
||||
setMenuAnchor(event.currentTarget);
|
||||
setSelectedProjectForMenu(project);
|
||||
};
|
||||
|
||||
const handleCloseMenu = () => {
|
||||
setMenuAnchor(null);
|
||||
setSelectedProjectForMenu(null);
|
||||
};
|
||||
|
||||
const handleEditClick = () => {
|
||||
setEditingProject({ ...selectedProjectForMenu });
|
||||
setEditDialogOpen(true);
|
||||
handleCloseMenu();
|
||||
};
|
||||
|
||||
const handleDetailClick = () => {
|
||||
setDetailDialogOpen(true);
|
||||
handleCloseMenu();
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setDialogOpen(false);
|
||||
setProjectCode('');
|
||||
setProjectName('');
|
||||
};
|
||||
|
||||
const handleCloseEditDialog = () => {
|
||||
setEditDialogOpen(false);
|
||||
setEditingProject(null);
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'active': return 'success';
|
||||
case 'inactive': return 'warning';
|
||||
case 'completed': return 'info';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'active': return <CheckCircle />;
|
||||
case 'inactive': return <Warning />;
|
||||
default: return <Assignment />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h4">
|
||||
🗂️ 프로젝트 관리
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
새 프로젝트
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* 전역 Toast */}
|
||||
<Toast
|
||||
open={toast.open}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ open: false, message: '', type: 'info' })}
|
||||
/>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Assignment sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
프로젝트가 없습니다
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 3 }}>
|
||||
새 프로젝트를 생성하여 시작하세요!
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
첫 번째 프로젝트 생성
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{projects.map((project) => (
|
||||
<Grid item xs={12} md={6} lg={4} key={project.id}>
|
||||
<Card
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
border: selectedProject?.id === project.id ? 2 : 1,
|
||||
borderColor: selectedProject?.id === project.id ? 'primary.main' : 'divider',
|
||||
'&:hover': {
|
||||
boxShadow: 3,
|
||||
borderColor: 'primary.main'
|
||||
}
|
||||
}}
|
||||
onClick={() => setSelectedProject(project)}
|
||||
>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{project.project_name || project.official_project_code}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="primary" sx={{ mb: 1 }}>
|
||||
코드: {project.official_project_code}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={project.status}
|
||||
size="small"
|
||||
color={getStatusColor(project.status)}
|
||||
icon={getStatusIcon(project.status)}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
생성일: {new Date(project.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOpenMenu(e, project);
|
||||
}}
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* 프로젝트 메뉴 */}
|
||||
<Menu
|
||||
anchorEl={menuAnchor}
|
||||
open={Boolean(menuAnchor)}
|
||||
onClose={handleCloseMenu}
|
||||
>
|
||||
<MenuItem onClick={handleDetailClick}>
|
||||
<Visibility sx={{ mr: 1 }} />
|
||||
상세 보기
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleEditClick}>
|
||||
<Edit sx={{ mr: 1 }} />
|
||||
수정
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleDeleteProject(selectedProjectForMenu);
|
||||
handleCloseMenu();
|
||||
}}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<Delete sx={{ mr: 1 }} />
|
||||
삭제
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* 새 프로젝트 생성 다이얼로그 */}
|
||||
<Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>새 프로젝트 생성</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="프로젝트 코드"
|
||||
placeholder="예: MP7-PIPING-R3"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={projectCode}
|
||||
onChange={(e) => setProjectCode(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="프로젝트명"
|
||||
placeholder="예: MP7 PIPING PROJECT Rev.3"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog} disabled={loading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateProject}
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{loading ? '생성 중...' : '생성'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 프로젝트 수정 다이얼로그 */}
|
||||
<Dialog open={editDialogOpen} onClose={handleCloseEditDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>프로젝트 수정</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="프로젝트 코드"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={editingProject?.official_project_code || ''}
|
||||
disabled
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="프로젝트명"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={editingProject?.project_name || ''}
|
||||
onChange={(e) => setEditingProject({
|
||||
...editingProject,
|
||||
project_name: e.target.value
|
||||
})}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
margin="dense"
|
||||
label="상태"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={editingProject?.status || 'active'}
|
||||
onChange={(e) => setEditingProject({
|
||||
...editingProject,
|
||||
status: e.target.value
|
||||
})}
|
||||
>
|
||||
<MenuItem key="active" value="active">활성</MenuItem>
|
||||
<MenuItem key="inactive" value="inactive">비활성</MenuItem>
|
||||
<MenuItem key="completed" value="completed">완료</MenuItem>
|
||||
</TextField>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseEditDialog} disabled={loading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEditProject}
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{loading ? '수정 중...' : '수정'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 프로젝트 상세 보기 다이얼로그 */}
|
||||
<Dialog open={detailDialogOpen} onClose={() => setDetailDialogOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>프로젝트 상세 정보</DialogTitle>
|
||||
<DialogContent>
|
||||
{selectedProjectForMenu && (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">프로젝트 코드</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{selectedProjectForMenu.official_project_code}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">프로젝트명</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{selectedProjectForMenu.project_name}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">상태</Typography>
|
||||
<Chip
|
||||
label={selectedProjectForMenu.status}
|
||||
color={getStatusColor(selectedProjectForMenu.status)}
|
||||
icon={getStatusIcon(selectedProjectForMenu.status)}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">생성일</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{new Date(selectedProjectForMenu.created_at).toLocaleString()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" color="textSecondary">설계 프로젝트 코드</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{selectedProjectForMenu.design_project_code || '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" color="textSecondary">코드 매칭</Typography>
|
||||
<Chip
|
||||
label={selectedProjectForMenu.is_code_matched ? '매칭됨' : '매칭 안됨'}
|
||||
color={selectedProjectForMenu.is_code_matched ? 'success' : 'warning'}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDetailDialogOpen(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectManager;
|
||||
319
tkeg/web/src/components/ProjectSelector.jsx
Normal file
319
tkeg/web/src/components/ProjectSelector.jsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
const ProjectSelector = ({ onProjectSelect, selectedProject }) => {
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
}, []);
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const response = await api.get('/jobs/');
|
||||
console.log('프로젝트 API 응답:', response.data);
|
||||
|
||||
// API 응답 구조에 맞게 처리
|
||||
let projectsData = [];
|
||||
if (response.data && response.data.success && Array.isArray(response.data.jobs)) {
|
||||
// 실제 API 데이터를 프론트엔드 형식에 맞게 변환
|
||||
projectsData = response.data.jobs.map(job => ({
|
||||
job_no: job.job_no,
|
||||
project_name: job.project_name || job.job_name,
|
||||
status: job.status === '진행중' ? 'active' : 'completed',
|
||||
progress: job.status === '진행중' ? 75 : 100, // 임시 진행률
|
||||
client_name: job.client_name,
|
||||
project_site: job.project_site,
|
||||
delivery_date: job.delivery_date
|
||||
}));
|
||||
}
|
||||
|
||||
// 데이터가 없으면 목 데이터 사용
|
||||
if (projectsData.length === 0) {
|
||||
projectsData = [
|
||||
{ job_no: 'TK-2024-001', project_name: '냉동기 시스템', status: 'active', progress: 75 },
|
||||
{ job_no: 'TK-2024-002', project_name: 'BOG 처리 시스템', status: 'active', progress: 45 },
|
||||
{ job_no: 'TK-2024-003', project_name: '다이아프램 펌프', status: 'active', progress: 90 },
|
||||
{ job_no: 'TK-2024-004', project_name: '드라이어 시스템', status: 'completed', progress: 100 },
|
||||
{ job_no: 'TK-2024-005', project_name: '열교환기 시스템', status: 'active', progress: 30 }
|
||||
];
|
||||
}
|
||||
|
||||
setProjects(projectsData);
|
||||
} catch (error) {
|
||||
console.error('프로젝트 목록 로딩 실패:', error);
|
||||
// 목 데이터 사용
|
||||
const mockProjects = [
|
||||
{ job_no: 'TK-2024-001', project_name: '냉동기 시스템', status: 'active', progress: 75 },
|
||||
{ job_no: 'TK-2024-002', project_name: 'BOG 처리 시스템', status: 'active', progress: 45 },
|
||||
{ job_no: 'TK-2024-003', project_name: '다이아프램 펌프', status: 'active', progress: 90 },
|
||||
{ job_no: 'TK-2024-004', project_name: '드라이어 시스템', status: 'completed', progress: 100 },
|
||||
{ job_no: 'TK-2024-005', project_name: '열교환기 시스템', status: 'active', progress: 30 }
|
||||
];
|
||||
setProjects(mockProjects);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredProjects = projects.filter(project =>
|
||||
project.project_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
project.job_no.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
'active': '#48bb78',
|
||||
'completed': '#38b2ac',
|
||||
'on_hold': '#ed8936',
|
||||
'cancelled': '#e53e3e'
|
||||
};
|
||||
return colors[status] || '#718096';
|
||||
};
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
'active': '진행중',
|
||||
'completed': '완료',
|
||||
'on_hold': '보류',
|
||||
'cancelled': '취소'
|
||||
};
|
||||
return texts[status] || '알 수 없음';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
color: '#666'
|
||||
}}>
|
||||
프로젝트 목록을 불러오는 중...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
{/* 선택된 프로젝트 표시 또는 선택 버튼 */}
|
||||
<div
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
background: selectedProject ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'white',
|
||||
color: selectedProject ? 'white' : '#2d3748',
|
||||
border: selectedProject ? 'none' : '2px dashed #cbd5e0',
|
||||
borderRadius: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
boxShadow: selectedProject ? '0 4px 12px rgba(102, 126, 234, 0.3)' : '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!selectedProject) {
|
||||
e.target.style.borderColor = '#667eea';
|
||||
e.target.style.backgroundColor = '#f7fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!selectedProject) {
|
||||
e.target.style.borderColor = '#cbd5e0';
|
||||
e.target.style.backgroundColor = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{selectedProject ? (
|
||||
<div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '4px' }}>
|
||||
{selectedProject.project_name}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', opacity: '0.9' }}>
|
||||
{selectedProject.job_no} • {getStatusText(selectedProject.status)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '4px' }}>
|
||||
🎯 프로젝트를 선택하세요
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#718096' }}>
|
||||
작업할 프로젝트를 선택하면 관련 업무를 시작할 수 있습니다
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '20px' }}>
|
||||
{showDropdown ? '🔼' : '🔽'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 드롭다운 메뉴 */}
|
||||
{showDropdown && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
marginTop: '8px',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.15)',
|
||||
border: '1px solid #e2e8f0',
|
||||
zIndex: 1000,
|
||||
maxHeight: '400px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* 검색 입력 */}
|
||||
<div style={{ padding: '16px', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="프로젝트 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #cbd5e0',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#667eea'}
|
||||
onBlur={(e) => e.target.style.borderColor = '#cbd5e0'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 목록 */}
|
||||
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||
{filteredProjects.length === 0 ? (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
color: '#718096'
|
||||
}}>
|
||||
검색 결과가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
filteredProjects.map((project) => (
|
||||
<div
|
||||
key={project.job_no}
|
||||
onClick={() => {
|
||||
onProjectSelect(project);
|
||||
setShowDropdown(false);
|
||||
setSearchTerm('');
|
||||
}}
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
cursor: 'pointer',
|
||||
borderBottom: '1px solid #f7fafc',
|
||||
transition: 'background-color 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.backgroundColor = '#f7fafc';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{project.project_name}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
{project.job_no}
|
||||
</div>
|
||||
|
||||
{/* 진행률 바 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
height: '4px',
|
||||
backgroundColor: '#e2e8f0',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${project.progress || 0}%`,
|
||||
height: '100%',
|
||||
backgroundColor: getStatusColor(project.status),
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#718096',
|
||||
minWidth: '35px'
|
||||
}}>
|
||||
{project.progress || 0}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginLeft: '16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end'
|
||||
}}>
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: `${getStatusColor(project.status)}20`,
|
||||
color: getStatusColor(project.status),
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{getStatusText(project.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 드롭다운 외부 클릭 시 닫기 */}
|
||||
{showDropdown && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 999
|
||||
}}
|
||||
onClick={() => setShowDropdown(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectSelector;
|
||||
159
tkeg/web/src/components/ProtectedRoute.jsx
Normal file
159
tkeg/web/src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import LoginPage from '../pages/LoginPage';
|
||||
|
||||
const ProtectedRoute = ({
|
||||
children,
|
||||
requiredPermission = null,
|
||||
requiredRole = null,
|
||||
fallback = null
|
||||
}) => {
|
||||
const { isAuthenticated, isLoading, user, hasPermission, hasRole } = useAuth();
|
||||
|
||||
// 로딩 중일 때
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="loading-container" style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
background: '#f7fafc',
|
||||
color: '#718096'
|
||||
}}>
|
||||
<div className="loading-spinner-large" style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
border: '4px solid #e2e8f0',
|
||||
borderTop: '4px solid #667eea',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
marginBottom: '16px'
|
||||
}}></div>
|
||||
<p>인증 정보를 확인하는 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 인증되지 않은 경우
|
||||
if (!isAuthenticated) {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
// 특정 권한이 필요한 경우
|
||||
if (requiredPermission && !hasPermission(requiredPermission)) {
|
||||
return fallback || (
|
||||
<div className="access-denied-container">
|
||||
<div className="access-denied-content">
|
||||
<div className="access-denied-icon">🔒</div>
|
||||
<h2>접근 권한이 없습니다</h2>
|
||||
<p>이 페이지에 접근하기 위한 권한이 없습니다.</p>
|
||||
<p className="permission-info">
|
||||
필요한 권한: <code>{requiredPermission}</code>
|
||||
</p>
|
||||
<div className="user-info">
|
||||
<p>현재 사용자: <strong>{user?.name}</strong> ({user?.username})</p>
|
||||
<p>역할: <strong>{user?.role}</strong></p>
|
||||
<p>접근 레벨: <strong>{user?.access_level}</strong></p>
|
||||
</div>
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
이전 페이지로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 특정 역할이 필요한 경우
|
||||
if (requiredRole && !hasRole(requiredRole)) {
|
||||
return fallback || (
|
||||
<div className="access-denied-container">
|
||||
<div className="access-denied-content">
|
||||
<div className="access-denied-icon">👤</div>
|
||||
<h2>역할 권한이 없습니다</h2>
|
||||
<p>이 페이지에 접근하기 위한 역할 권한이 없습니다.</p>
|
||||
<p className="role-info">
|
||||
필요한 역할: <code>{requiredRole}</code>
|
||||
</p>
|
||||
<div className="user-info">
|
||||
<p>현재 사용자: <strong>{user?.name}</strong> ({user?.username})</p>
|
||||
<p>현재 역할: <strong>{user?.role}</strong></p>
|
||||
</div>
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
이전 페이지로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 모든 조건을 만족하는 경우 자식 컴포넌트 렌더링
|
||||
return children;
|
||||
};
|
||||
|
||||
// 관리자 전용 라우트
|
||||
export const AdminRoute = ({ children, fallback = null }) => {
|
||||
return (
|
||||
<ProtectedRoute
|
||||
requiredRole="admin"
|
||||
fallback={fallback}
|
||||
>
|
||||
{children}
|
||||
</ProtectedRoute>
|
||||
);
|
||||
};
|
||||
|
||||
// 시스템 관리자 전용 라우트
|
||||
export const SystemRoute = ({ children, fallback = null }) => {
|
||||
const { hasRole } = useAuth();
|
||||
|
||||
if (!hasRole('admin') && !hasRole('system')) {
|
||||
return fallback || (
|
||||
<div className="access-denied-container">
|
||||
<div className="access-denied-content">
|
||||
<div className="access-denied-icon">⚙️</div>
|
||||
<h2>시스템 관리자 권한이 필요합니다</h2>
|
||||
<p>이 페이지는 시스템 관리자만 접근할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
{children}
|
||||
</ProtectedRoute>
|
||||
);
|
||||
};
|
||||
|
||||
// 매니저 이상 권한 라우트
|
||||
export const ManagerRoute = ({ children, fallback = null }) => {
|
||||
const { isManager } = useAuth();
|
||||
|
||||
if (!isManager()) {
|
||||
return fallback || (
|
||||
<div className="access-denied-container">
|
||||
<div className="access-denied-content">
|
||||
<div className="access-denied-icon">👔</div>
|
||||
<h2>관리자 권한이 필요합니다</h2>
|
||||
<p>이 페이지는 관리자 이상의 권한이 필요합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
{children}
|
||||
</ProtectedRoute>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
104
tkeg/web/src/components/RevisionUploadDialog.jsx
Normal file
104
tkeg/web/src/components/RevisionUploadDialog.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
|
||||
const RevisionUploadDialog = ({
|
||||
revisionDialog,
|
||||
setRevisionDialog,
|
||||
revisionFile,
|
||||
setRevisionFile,
|
||||
handleRevisionUpload,
|
||||
uploading
|
||||
}) => {
|
||||
if (!revisionDialog.open) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
maxWidth: '500px',
|
||||
width: '90%'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0' }}>
|
||||
리비전 업로드: {revisionDialog.bomName}
|
||||
</h3>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => setRevisionFile(e.target.files[0])}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginBottom: '16px',
|
||||
padding: '8px'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setRevisionDialog({ open: false, bomName: '', parentId: null })}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#e2e8f0',
|
||||
color: '#4a5568',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRevisionUpload}
|
||||
disabled={!revisionFile || uploading}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: (!revisionFile || uploading) ? '#e2e8f0' : '#4299e1',
|
||||
color: (!revisionFile || uploading) ? '#a0aec0' : 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: (!revisionFile || uploading) ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '업로드'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RevisionUploadDialog;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
323
tkeg/web/src/components/SimpleFileUpload.jsx
Normal file
323
tkeg/web/src/components/SimpleFileUpload.jsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import React, { useState } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
const SimpleFileUpload = ({ selectedProject, onUploadComplete }) => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadResult, setUploadResult] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
||||
const handleDrag = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleFileUpload(e.dataTransfer.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
handleFileUpload(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file) => {
|
||||
if (!selectedProject) {
|
||||
setError('프로젝트를 먼저 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 유효성 검사
|
||||
const allowedTypes = ['.xlsx', '.xls', '.csv'];
|
||||
const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
|
||||
|
||||
if (!allowedTypes.includes(fileExtension)) {
|
||||
setError(`지원하지 않는 파일 형식입니다. 허용된 확장자: ${allowedTypes.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setError('파일 크기는 10MB를 초과할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError('');
|
||||
setUploadResult(null);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('job_no', selectedProject.job_no);
|
||||
formData.append('revision', 'Rev.0');
|
||||
|
||||
// 업로드 진행률 시뮬레이션
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadProgress(prev => {
|
||||
if (prev >= 90) {
|
||||
clearInterval(progressInterval);
|
||||
return 90;
|
||||
}
|
||||
return prev + 10;
|
||||
});
|
||||
}, 200);
|
||||
|
||||
const response = await api.post('/files/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setUploadProgress(100);
|
||||
|
||||
if (response.data.success) {
|
||||
setUploadResult({
|
||||
success: true,
|
||||
message: response.data.message,
|
||||
file: response.data.file,
|
||||
job: response.data.job,
|
||||
sampleMaterials: response.data.sample_materials || []
|
||||
});
|
||||
|
||||
// 업로드 완료 콜백 호출
|
||||
if (onUploadComplete) {
|
||||
onUploadComplete(response.data);
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.data.message || '업로드 실패');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('업로드 에러:', err);
|
||||
setError(err.response?.data?.detail || err.message || '파일 업로드에 실패했습니다.');
|
||||
setUploadProgress(0);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 드래그 앤 드롭 영역 */}
|
||||
<div
|
||||
style={{
|
||||
border: `2px dashed ${dragActive ? '#667eea' : '#e2e8f0'}`,
|
||||
borderRadius: '12px',
|
||||
padding: '40px 20px',
|
||||
textAlign: 'center',
|
||||
background: dragActive ? '#f7fafc' : 'white',
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '20px'
|
||||
}}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => document.getElementById('file-input').click()}
|
||||
>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>
|
||||
{uploading ? '⏳' : '📤'}
|
||||
</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', marginBottom: '8px' }}>
|
||||
{uploading ? '업로드 중...' : 'BOM 파일을 업로드하세요'}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#718096', marginBottom: '16px' }}>
|
||||
파일을 드래그하거나 클릭하여 선택하세요
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#a0aec0' }}>
|
||||
지원 형식: Excel (.xlsx, .xls), CSV (.csv) | 최대 크기: 10MB
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 업로드 진행률 */}
|
||||
{uploading && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||
업로드 진행률
|
||||
</span>
|
||||
<span style={{ fontSize: '14px', color: '#667eea' }}>
|
||||
{uploadProgress}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '8px',
|
||||
background: '#e2e8f0',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${uploadProgress}%`,
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, #667eea, #764ba2)',
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fed7d7',
|
||||
border: '1px solid #fc8181',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
marginBottom: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span style={{ color: '#c53030', fontSize: '16px' }}>⚠️</span>
|
||||
<span style={{ color: '#c53030', fontSize: '14px' }}>{error}</span>
|
||||
<button
|
||||
onClick={() => setError('')}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#c53030',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드 성공 결과 */}
|
||||
{uploadResult && uploadResult.success && (
|
||||
<div style={{
|
||||
background: '#c6f6d5',
|
||||
border: '1px solid #68d391',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||
<span style={{ color: '#2f855a', fontSize: '20px' }}>✅</span>
|
||||
<span style={{ color: '#2f855a', fontSize: '16px', fontWeight: '600' }}>
|
||||
업로드 완료!
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ color: '#2f855a', fontSize: '14px', marginBottom: '16px' }}>
|
||||
{uploadResult.message}
|
||||
</div>
|
||||
|
||||
{/* 파일 정보 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px 0', color: '#2d3748', fontSize: '14px' }}>
|
||||
📄 파일 정보
|
||||
</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', fontSize: '12px' }}>
|
||||
<div><strong>파일명:</strong> {uploadResult.file?.original_filename}</div>
|
||||
<div><strong>분석된 자재:</strong> {uploadResult.file?.parsed_count}개</div>
|
||||
<div><strong>저장된 자재:</strong> {uploadResult.file?.saved_count}개</div>
|
||||
<div><strong>프로젝트:</strong> {uploadResult.job?.job_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 샘플 자재 미리보기 */}
|
||||
{uploadResult.sampleMaterials && uploadResult.sampleMaterials.length > 0 && (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '16px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px 0', color: '#2d3748', fontSize: '14px' }}>
|
||||
🔧 자재 샘플 (처음 3개)
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{uploadResult.sampleMaterials.map((material, index) => (
|
||||
<div key={index} style={{
|
||||
padding: '8px 12px',
|
||||
background: '#f7fafc',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
color: '#4a5568'
|
||||
}}>
|
||||
<strong>{material.description || material.item_code}</strong>
|
||||
{material.category && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#667eea',
|
||||
color: 'white',
|
||||
borderRadius: '3px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{material.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleFileUpload;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
426
tkeg/web/src/components/SpoolManager.jsx
Normal file
426
tkeg/web/src/components/SpoolManager.jsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
TextField,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Grid,
|
||||
Chip,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
Build,
|
||||
CheckCircle,
|
||||
Error,
|
||||
Visibility,
|
||||
Edit,
|
||||
Delete,
|
||||
MoreVert
|
||||
} from '@mui/icons-material';
|
||||
import { fetchProjectSpools, validateSpoolIdentifier, generateSpoolIdentifier } from '../api';
|
||||
import Toast from './Toast';
|
||||
|
||||
function SpoolManager({ selectedProject }) {
|
||||
const [spools, setSpools] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [validationDialogOpen, setValidationDialogOpen] = useState(false);
|
||||
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
|
||||
|
||||
// 스풀 생성 폼 상태
|
||||
const [newSpool, setNewSpool] = useState({
|
||||
dwg_name: '',
|
||||
area_number: '',
|
||||
spool_number: ''
|
||||
});
|
||||
|
||||
// 유효성 검증 상태
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
const [validating, setValidating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
fetchSpools();
|
||||
} else {
|
||||
setSpools([]);
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
const fetchSpools = async () => {
|
||||
if (!selectedProject) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchProjectSpools(selectedProject.id);
|
||||
if (response.data && response.data.spools) {
|
||||
setSpools(response.data.spools);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('스풀 조회 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '스풀 데이터를 불러오는데 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSpool = async () => {
|
||||
if (!newSpool.dwg_name || !newSpool.area_number || !newSpool.spool_number) {
|
||||
setToast({
|
||||
open: true,
|
||||
message: '도면명, 에리어 번호, 스풀 번호를 모두 입력해주세요.',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await generateSpoolIdentifier(
|
||||
newSpool.dwg_name,
|
||||
newSpool.area_number,
|
||||
newSpool.spool_number
|
||||
);
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
setToast({
|
||||
open: true,
|
||||
message: '스풀이 성공적으로 생성되었습니다.',
|
||||
type: 'success'
|
||||
});
|
||||
setDialogOpen(false);
|
||||
setNewSpool({ dwg_name: '', area_number: '', spool_number: '' });
|
||||
fetchSpools(); // 목록 새로고침
|
||||
} else {
|
||||
setToast({
|
||||
open: true,
|
||||
message: response.data?.message || '스풀 생성에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('스풀 생성 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '스풀 생성 중 오류가 발생했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidateSpool = async (identifier) => {
|
||||
setValidating(true);
|
||||
try {
|
||||
const response = await validateSpoolIdentifier(identifier);
|
||||
setValidationResult(response.data);
|
||||
setValidationDialogOpen(true);
|
||||
} catch (error) {
|
||||
console.error('스풀 유효성 검증 실패:', error);
|
||||
setToast({
|
||||
open: true,
|
||||
message: '스풀 유효성 검증에 실패했습니다.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'active': return 'success';
|
||||
case 'inactive': return 'warning';
|
||||
case 'completed': return 'info';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
🔧 스풀 관리
|
||||
</Typography>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Build sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
프로젝트를 선택해주세요
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
프로젝트 관리 탭에서 프로젝트를 선택하면 스풀을 관리할 수 있습니다.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h4">
|
||||
🔧 스풀 관리
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
새 스풀
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
|
||||
{selectedProject.project_name} ({selectedProject.official_project_code})
|
||||
</Typography>
|
||||
|
||||
{/* 전역 Toast */}
|
||||
<Toast
|
||||
open={toast.open}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ open: false, message: '', type: 'info' })}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" sx={{ mt: 2 }}>
|
||||
스풀 데이터 로딩 중...
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : spools.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Build sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
스풀이 없습니다
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 3 }}>
|
||||
새 스풀을 생성하여 시작하세요!
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
첫 번째 스풀 생성
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6">
|
||||
총 {spools.length}개 스풀
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${spools.length}개 표시 중`}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||
<TableCell><strong>스풀 식별자</strong></TableCell>
|
||||
<TableCell><strong>도면명</strong></TableCell>
|
||||
<TableCell><strong>에리어</strong></TableCell>
|
||||
<TableCell><strong>스풀 번호</strong></TableCell>
|
||||
<TableCell align="center"><strong>자재 수</strong></TableCell>
|
||||
<TableCell align="center"><strong>총 수량</strong></TableCell>
|
||||
<TableCell><strong>상태</strong></TableCell>
|
||||
<TableCell align="center"><strong>작업</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{spools.map((spool) => (
|
||||
<TableRow
|
||||
key={spool.id}
|
||||
sx={{ '&:hover': { bgcolor: 'grey.50' } }}
|
||||
>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
{spool.spool_identifier}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{spool.dwg_name}</TableCell>
|
||||
<TableCell>{spool.area_number}</TableCell>
|
||||
<TableCell>{spool.spool_number}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={spool.material_count || 0}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={(spool.total_quantity || 0).toLocaleString()}
|
||||
size="small"
|
||||
color="success"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={spool.status || 'active'}
|
||||
size="small"
|
||||
color={getStatusColor(spool.status)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleValidateSpool(spool.spool_identifier)}
|
||||
disabled={validating}
|
||||
>
|
||||
<Visibility />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 새 스풀 생성 다이얼로그 */}
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>새 스풀 생성</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="도면명"
|
||||
placeholder="예: MP7-PIPING-001"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={newSpool.dwg_name}
|
||||
onChange={(e) => setNewSpool({ ...newSpool, dwg_name: e.target.value })}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="에리어 번호"
|
||||
placeholder="예: A1"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={newSpool.area_number}
|
||||
onChange={(e) => setNewSpool({ ...newSpool, area_number: e.target.value })}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="스풀 번호"
|
||||
placeholder="예: 001"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={newSpool.spool_number}
|
||||
onChange={(e) => setNewSpool({ ...newSpool, spool_number: e.target.value })}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)} disabled={loading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateSpool}
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{loading ? '생성 중...' : '생성'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 스풀 유효성 검증 결과 다이얼로그 */}
|
||||
<Dialog open={validationDialogOpen} onClose={() => setValidationDialogOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>스풀 유효성 검증 결과</DialogTitle>
|
||||
<DialogContent>
|
||||
{validationResult && (
|
||||
<Box>
|
||||
<Alert
|
||||
severity={validationResult.validation.is_valid ? 'success' : 'error'}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{validationResult.validation.is_valid ? '유효한 스풀 식별자입니다.' : '유효하지 않은 스풀 식별자입니다.'}
|
||||
</Alert>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">스풀 식별자</Typography>
|
||||
<Typography variant="body1" sx={{ fontFamily: 'monospace', mb: 2 }}>
|
||||
{validationResult.spool_identifier}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="textSecondary">검증 시간</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{new Date(validationResult.timestamp).toLocaleString()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" color="textSecondary">검증 세부사항</Typography>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{validationResult.validation.details &&
|
||||
Object.entries(validationResult.validation.details).map(([key, value]) => (
|
||||
<Chip
|
||||
key={key}
|
||||
label={`${key}: ${value}`}
|
||||
size="small"
|
||||
color={value ? 'success' : 'error'}
|
||||
sx={{ mr: 1, mb: 1 }}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setValidationDialogOpen(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpoolManager;
|
||||
74
tkeg/web/src/components/Toast.jsx
Normal file
74
tkeg/web/src/components/Toast.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Snackbar, Alert, AlertTitle } from '@mui/material';
|
||||
import {
|
||||
CheckCircle,
|
||||
Error,
|
||||
Warning,
|
||||
Info
|
||||
} from '@mui/icons-material';
|
||||
|
||||
const Toast = React.memo(({
|
||||
open,
|
||||
message,
|
||||
type = 'info',
|
||||
title,
|
||||
autoHideDuration = 4000,
|
||||
onClose,
|
||||
anchorOrigin = { vertical: 'top', horizontal: 'center' }
|
||||
}) => {
|
||||
const getSeverity = () => {
|
||||
switch (type) {
|
||||
case 'success': return 'success';
|
||||
case 'error': return 'error';
|
||||
case 'warning': return 'warning';
|
||||
case 'info':
|
||||
default: return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'success': return <CheckCircle />;
|
||||
case 'error': return <Error />;
|
||||
case 'warning': return <Warning />;
|
||||
case 'info':
|
||||
default: return <Info />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={autoHideDuration}
|
||||
onClose={onClose}
|
||||
anchorOrigin={anchorOrigin}
|
||||
>
|
||||
<Alert
|
||||
onClose={onClose}
|
||||
severity={getSeverity()}
|
||||
icon={getIcon()}
|
||||
sx={{
|
||||
width: '100%',
|
||||
minWidth: 300,
|
||||
maxWidth: 600
|
||||
}}
|
||||
>
|
||||
{title && <AlertTitle>{title}</AlertTitle>}
|
||||
{message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
});
|
||||
|
||||
Toast.propTypes = {
|
||||
open: PropTypes.bool.isRequired,
|
||||
message: PropTypes.string.isRequired,
|
||||
type: PropTypes.oneOf(['success', 'error', 'warning', 'info']),
|
||||
title: PropTypes.string,
|
||||
autoHideDuration: PropTypes.number,
|
||||
onClose: PropTypes.func,
|
||||
anchorOrigin: PropTypes.object,
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
3
tkeg/web/src/components/bom/index.js
Normal file
3
tkeg/web/src/components/bom/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// BOM Components
|
||||
export * from './materials';
|
||||
export * from './shared';
|
||||
742
tkeg/web/src/components/bom/materials/BoltMaterialsView.jsx
Normal file
742
tkeg/web/src/components/bom/materials/BoltMaterialsView.jsx
Normal file
@@ -0,0 +1,742 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader } from '../shared';
|
||||
|
||||
const BoltMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
jobNo,
|
||||
fileId,
|
||||
user
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedRequestsData = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
}
|
||||
});
|
||||
|
||||
setSavedRequests(savedRequestsData);
|
||||
};
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
// 볼트 추가요구사항 추출 함수
|
||||
const extractBoltAdditionalRequirements = (description) => {
|
||||
const additionalReqs = [];
|
||||
|
||||
// 표면처리 패턴 확인
|
||||
const surfacePatterns = {
|
||||
'ELEC.GALV': 'ELEC.GALV',
|
||||
'ELEC GALV': 'ELEC.GALV',
|
||||
'GALVANIZED': 'GALVANIZED',
|
||||
'GALV': 'GALV',
|
||||
'HOT DIP GALV': 'HDG',
|
||||
'HDG': 'HDG',
|
||||
'ZINC PLATED': 'ZINC PLATED',
|
||||
'ZINC': 'ZINC',
|
||||
'PLAIN': 'PLAIN'
|
||||
};
|
||||
|
||||
for (const [pattern, treatment] of Object.entries(surfacePatterns)) {
|
||||
if (description.includes(pattern)) {
|
||||
additionalReqs.push(treatment);
|
||||
break; // 첫 번째 매치만 사용
|
||||
}
|
||||
}
|
||||
|
||||
return additionalReqs.join(', ') || '-';
|
||||
};
|
||||
|
||||
const parseBoltInfo = (material) => {
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
|
||||
// 플랜지당 볼트 세트 수 추출 (예: (8), (4))
|
||||
const boltDetails = material.bolt_details || {};
|
||||
let boltsPerFlange = boltDetails.dimensions?.bolts_per_flange || 1;
|
||||
|
||||
// 백엔드에서 정보가 없으면 원본 설명에서 직접 추출
|
||||
if (boltsPerFlange === 1) {
|
||||
const description = material.original_description || '';
|
||||
const flangePattern = description.match(/\((\d+)\)/);
|
||||
if (flangePattern) {
|
||||
boltsPerFlange = parseInt(flangePattern[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// 실제 필요한 볼트 수 = 플랜지 수 × 플랜지당 볼트 세트 수
|
||||
const totalBoltsNeeded = qty * boltsPerFlange;
|
||||
const safetyQty = Math.ceil(totalBoltsNeeded * 1.05); // 5% 여유율
|
||||
const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수
|
||||
|
||||
// 길이 정보 (bolt_details 우선, 없으면 원본 설명에서 추출)
|
||||
let boltLength = '-';
|
||||
if (boltDetails.length && boltDetails.length !== '-') {
|
||||
boltLength = boltDetails.length;
|
||||
} else {
|
||||
// 원본 설명에서 길이 추출
|
||||
const description = material.original_description || '';
|
||||
const lengthPatterns = [
|
||||
/(\d+(?:\.\d+)?)\s*LG/i, // 75 LG, 90.0000 LG
|
||||
/(\d+(?:\.\d+)?)\s*mm/i, // 50mm
|
||||
/(\d+(?:\.\d+)?)\s*MM/i, // 50MM
|
||||
/LG[,\s]*(\d+(?:\.\d+)?)/i // LG, 75 형태
|
||||
];
|
||||
|
||||
for (const pattern of lengthPatterns) {
|
||||
const match = description.match(pattern);
|
||||
if (match) {
|
||||
let lengthValue = match[1];
|
||||
// 소수점 제거 (145.0000 → 145)
|
||||
if (lengthValue.includes('.') && lengthValue.endsWith('.0000')) {
|
||||
lengthValue = lengthValue.split('.')[0];
|
||||
} else if (lengthValue.includes('.') && /\.0+$/.test(lengthValue)) {
|
||||
lengthValue = lengthValue.split('.')[0];
|
||||
}
|
||||
boltLength = `${lengthValue}mm`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 재질 정보 (bolt_details 우선, 없으면 기본 필드 사용)
|
||||
let boltGrade = '-';
|
||||
if (boltDetails.material_standard && boltDetails.material_grade) {
|
||||
// bolt_details에서 완전한 재질 정보 구성
|
||||
if (boltDetails.material_grade !== 'UNKNOWN' && boltDetails.material_grade !== 'UNCLASSIFIED' && boltDetails.material_grade !== boltDetails.material_standard) {
|
||||
boltGrade = `${boltDetails.material_standard} ${boltDetails.material_grade}`;
|
||||
} else {
|
||||
boltGrade = boltDetails.material_standard;
|
||||
}
|
||||
} else if (material.full_material_grade && material.full_material_grade !== '-') {
|
||||
boltGrade = material.full_material_grade;
|
||||
} else if (material.material_grade && material.material_grade !== '-') {
|
||||
boltGrade = material.material_grade;
|
||||
}
|
||||
|
||||
// 볼트 타입 (PSV_BOLT, LT_BOLT 등)
|
||||
let boltSubtype = 'BOLT_GENERAL';
|
||||
if (boltDetails.bolt_type && boltDetails.bolt_type !== 'UNKNOWN' && boltDetails.bolt_type !== 'UNCLASSIFIED') {
|
||||
boltSubtype = boltDetails.bolt_type;
|
||||
} else {
|
||||
// 원본 설명에서 특수 볼트 타입 추출
|
||||
const description = material.original_description || '';
|
||||
const upperDesc = description.toUpperCase();
|
||||
if (upperDesc.includes('PSV')) {
|
||||
boltSubtype = 'PSV_BOLT';
|
||||
} else if (upperDesc.includes('LT')) {
|
||||
boltSubtype = 'LT_BOLT';
|
||||
} else if (upperDesc.includes('CK')) {
|
||||
boltSubtype = 'CK_BOLT';
|
||||
}
|
||||
}
|
||||
|
||||
// 압력 등급 추출 (150LB 등)
|
||||
let boltPressure = '-';
|
||||
const description = material.original_description || '';
|
||||
const pressureMatch = description.match(/(\d+)\s*LB/i);
|
||||
if (pressureMatch) {
|
||||
boltPressure = `${pressureMatch[1]}LB`;
|
||||
}
|
||||
|
||||
// User Requirements 추출 (ELEC.GALV 등)
|
||||
const userRequirements = extractBoltAdditionalRequirements(material.original_description || '');
|
||||
|
||||
// Purchase Quantity (Quantity + Unit 통합) - 플랜지당 볼트 세트 수 정보 포함
|
||||
const purchaseQuantity = boltsPerFlange > 1
|
||||
? `${purchaseQty} SETS (${boltsPerFlange}/flange)`
|
||||
: `${purchaseQty} SETS`;
|
||||
|
||||
return {
|
||||
type: 'BOLT',
|
||||
subtype: boltSubtype,
|
||||
size: material.size_spec || material.main_nom || '-',
|
||||
pressure: boltPressure, // 압력 등급 (150LB 등)
|
||||
schedule: boltLength, // 길이 정보
|
||||
grade: boltGrade,
|
||||
userRequirements: userRequirements, // User Requirements (ELEC.GALV 등)
|
||||
additionalReq: '-', // 추가요구사항 (사용자 입력)
|
||||
purchaseQuantity: purchaseQuantity // 구매수량 (통합)
|
||||
};
|
||||
};
|
||||
|
||||
// 정렬 처리
|
||||
const handleSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
// 필터링된 및 정렬된 자재 목록
|
||||
const getFilteredAndSortedMaterials = () => {
|
||||
let filtered = materials.filter(material => {
|
||||
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||
if (!filterValue) return true;
|
||||
const info = parseBoltInfo(material);
|
||||
const value = info[key]?.toString().toLowerCase() || '';
|
||||
return value.includes(filterValue.toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
if (sortConfig && sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = parseBoltInfo(a);
|
||||
const bInfo = parseBoltInfo(b);
|
||||
|
||||
if (!aInfo || !bInfo) return 0;
|
||||
|
||||
const aValue = aInfo[sortConfig.key];
|
||||
const bValue = bInfo[sortConfig.key];
|
||||
|
||||
// 값이 없는 경우 처리
|
||||
if (aValue === undefined && bValue === undefined) return 0;
|
||||
if (aValue === undefined) return 1;
|
||||
if (bValue === undefined) return -1;
|
||||
|
||||
// 숫자인 경우 숫자로 비교
|
||||
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
|
||||
}
|
||||
|
||||
// 문자열로 비교
|
||||
const aStr = String(aValue).toLowerCase();
|
||||
const bStr = String(bValue).toLowerCase();
|
||||
|
||||
if (sortConfig.direction === 'asc') {
|
||||
return aStr.localeCompare(bStr);
|
||||
} else {
|
||||
return bStr.localeCompare(aStr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// 전체 선택/해제 (구매신청된 자재 제외)
|
||||
const handleSelectAll = () => {
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
|
||||
if (selectedMaterials.size === selectableMaterials.length) {
|
||||
setSelectedMaterials(new Set());
|
||||
} else {
|
||||
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택 (구매신청된 자재는 선택 불가)
|
||||
const handleMaterialSelect = (materialId) => {
|
||||
if (purchasedMaterials.has(materialId)) {
|
||||
return; // 구매신청된 자재는 선택 불가
|
||||
}
|
||||
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (newSelected.has(materialId)) {
|
||||
newSelected.delete(materialId);
|
||||
} else {
|
||||
newSelected.add(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExportToExcel = async () => {
|
||||
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||
if (selectedMaterialsData.length === 0) {
|
||||
alert('내보낼 자재를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const excelFileName = `BOLT_Materials_${timestamp}.xlsx`;
|
||||
|
||||
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||
...material,
|
||||
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 볼트 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'BOLT',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||
|
||||
// 2. 구매신청 생성
|
||||
const allMaterialIds = selectedMaterialsData.map(m => m.id);
|
||||
const response = await api.post('/purchase-request/create', {
|
||||
file_id: fileId,
|
||||
job_no: jobNo,
|
||||
category: 'BOLT',
|
||||
material_ids: allMaterialIds,
|
||||
materials_data: dataWithRequirements.map(m => ({
|
||||
material_id: m.id,
|
||||
description: m.original_description,
|
||||
category: m.classified_category,
|
||||
size: m.size_inch || m.size_spec,
|
||||
schedule: m.schedule,
|
||||
material_grade: m.material_grade || m.full_material_grade,
|
||||
quantity: m.quantity,
|
||||
unit: m.unit,
|
||||
user_requirement: userRequirements[m.id] || ''
|
||||
}))
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||
|
||||
// 3. 엑셀 파일을 서버에 업로드
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', response.data.request_id);
|
||||
formData.append('category', 'BOLT');
|
||||
|
||||
console.log('📤 엑셀 파일 서버 업로드 중...');
|
||||
await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
console.log('✅ 엑셀 파일 서버 업로드 완료');
|
||||
|
||||
// 4. 구매된 자재 목록 업데이트 (비활성화)
|
||||
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||
console.log('✅ 구매된 자재 목록 업데이트 완료');
|
||||
|
||||
// 5. 클라이언트에 파일 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||
} else {
|
||||
throw new Error(response.data?.message || '구매신청 생성 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||
// 실패 시에도 클라이언트 다운로드는 진행
|
||||
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||
category: 'BOLT',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||
}
|
||||
|
||||
// 선택 해제
|
||||
setSelectedMaterials(new Set());
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
return (
|
||||
<div style={{ padding: '32px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
Bolt Materials
|
||||
</h3>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#64748b',
|
||||
margin: 0
|
||||
}}>
|
||||
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #6b7280 0%, #4b5563 100%)' : '#e5e7eb',
|
||||
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Export to Excel ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '600px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<div style={{ minWidth: '1500px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 200px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(() => {
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
|
||||
})()}
|
||||
onChange={handleSelectAll}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader
|
||||
sortKey="subtype"
|
||||
filterKey="subtype"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Type
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="size"
|
||||
filterKey="size"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Size
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="pressure"
|
||||
filterKey="pressure"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Pressure
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="schedule"
|
||||
filterKey="schedule"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Length
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="grade"
|
||||
filterKey="grade"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Material Grade
|
||||
</FilterableHeader>
|
||||
<div>User Requirements</div>
|
||||
<div>Additional Request</div>
|
||||
<FilterableHeader
|
||||
sortKey="purchaseQuantity"
|
||||
filterKey="purchaseQuantity"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Purchase Quantity
|
||||
</FilterableHeader>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
{filteredMaterials.map((material, index) => {
|
||||
const info = parseBoltInfo(material);
|
||||
const isSelected = selectedMaterials.has(material.id);
|
||||
const isPurchased = purchasedMaterials.has(material.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 200px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleMaterialSelect(material.id)}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||
{info.subtype}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.size}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.pressure}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.schedule}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.grade}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#1f2937',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{info.userRequirements}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||
disabled={isPurchased || savingRequest[material.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.purchaseQuantity}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#64748b'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
No Bolt Materials Found
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||
? 'Try adjusting your filters'
|
||||
: 'No bolt materials available in this BOM'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoltMaterialsView;
|
||||
898
tkeg/web/src/components/bom/materials/FittingMaterialsView.jsx
Normal file
898
tkeg/web/src/components/bom/materials/FittingMaterialsView.jsx
Normal file
@@ -0,0 +1,898 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader, MaterialTable } from '../shared';
|
||||
|
||||
const FittingMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
fileId,
|
||||
jobNo,
|
||||
user,
|
||||
onNavigate
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedRequestsData = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
}
|
||||
});
|
||||
|
||||
setSavedRequests(savedRequestsData);
|
||||
};
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
// 니플 끝단 정보 추출 (기존 로직 복원)
|
||||
const extractNippleEndInfo = (description) => {
|
||||
const descUpper = description.toUpperCase();
|
||||
|
||||
// 니플 끝단 패턴들 (기존 NewMaterialsPage와 동일)
|
||||
const endPatterns = {
|
||||
'PBE': 'PBE', // Plain Both End
|
||||
'BBE': 'BBE', // Bevel Both End
|
||||
'POE': 'POE', // Plain One End
|
||||
'BOE': 'BOE', // Bevel One End
|
||||
'TOE': 'TOE', // Thread One End
|
||||
'SW X NPT': 'SW×NPT', // Socket Weld x NPT
|
||||
'SW X SW': 'SW×SW', // Socket Weld x Socket Weld
|
||||
'NPT X NPT': 'NPT×NPT', // NPT x NPT
|
||||
'BOTH END THREADED': 'B.E.T',
|
||||
'B.E.T': 'B.E.T',
|
||||
'ONE END THREADED': 'O.E.T',
|
||||
'O.E.T': 'O.E.T',
|
||||
'THREADED': 'THD'
|
||||
};
|
||||
|
||||
for (const [pattern, display] of Object.entries(endPatterns)) {
|
||||
if (descUpper.includes(pattern)) {
|
||||
return display;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
// 피팅 정보 파싱 (기존 상세 로직 복원)
|
||||
const parseFittingInfo = (material) => {
|
||||
const fittingDetails = material.fitting_details || {};
|
||||
const classificationDetails = material.classification_details || {};
|
||||
|
||||
// 개선된 분류기 결과 우선 사용
|
||||
const fittingTypeInfo = classificationDetails.fitting_type || {};
|
||||
const scheduleInfo = classificationDetails.schedule_info || {};
|
||||
|
||||
// 기존 필드와 새 필드 통합
|
||||
const fittingType = fittingTypeInfo.type || fittingDetails.fitting_type || '';
|
||||
const fittingSubtype = fittingTypeInfo.subtype || fittingDetails.fitting_subtype || '';
|
||||
const mainSchedule = scheduleInfo.main_schedule || fittingDetails.schedule || '';
|
||||
const redSchedule = scheduleInfo.red_schedule || '';
|
||||
const hasDifferentSchedules = scheduleInfo.has_different_schedules || false;
|
||||
|
||||
const description = material.original_description || '';
|
||||
|
||||
// 피팅 타입별 상세 표시
|
||||
let displayType = '';
|
||||
|
||||
// 개선된 분류기 결과 우선 표시
|
||||
if (fittingType === 'TEE' && fittingSubtype === 'REDUCING') {
|
||||
displayType = 'TEE REDUCING';
|
||||
} else if (fittingType === 'REDUCER' && fittingSubtype === 'CONCENTRIC') {
|
||||
displayType = 'REDUCER CONC';
|
||||
} else if (fittingType === 'REDUCER' && fittingSubtype === 'ECCENTRIC') {
|
||||
displayType = 'REDUCER ECC';
|
||||
} else if (description.toUpperCase().includes('TEE RED')) {
|
||||
displayType = 'TEE REDUCING';
|
||||
} else if (description.toUpperCase().includes('RED CONC')) {
|
||||
displayType = 'REDUCER CONC';
|
||||
} else if (description.toUpperCase().includes('RED ECC')) {
|
||||
displayType = 'REDUCER ECC';
|
||||
} else if (description.toUpperCase().includes('CAP')) {
|
||||
if (description.includes('NPT(F)')) {
|
||||
displayType = 'CAP NPT(F)';
|
||||
} else if (description.includes('SW')) {
|
||||
displayType = 'CAP SW';
|
||||
} else if (description.includes('BW')) {
|
||||
displayType = 'CAP BW';
|
||||
} else {
|
||||
displayType = 'CAP';
|
||||
}
|
||||
} else if (description.toUpperCase().includes('PLUG')) {
|
||||
if (description.toUpperCase().includes('HEX')) {
|
||||
if (description.includes('NPT(M)')) {
|
||||
displayType = 'HEX PLUG NPT(M)';
|
||||
} else {
|
||||
displayType = 'HEX PLUG';
|
||||
}
|
||||
} else if (description.includes('NPT(M)')) {
|
||||
displayType = 'PLUG NPT(M)';
|
||||
} else if (description.includes('NPT')) {
|
||||
displayType = 'PLUG NPT';
|
||||
} else {
|
||||
displayType = 'PLUG';
|
||||
}
|
||||
} else if (fittingType === 'NIPPLE') {
|
||||
const length = fittingDetails.length_mm || fittingDetails.avg_length_mm;
|
||||
const endInfo = extractNippleEndInfo(description);
|
||||
|
||||
let nippleType = 'NIPPLE';
|
||||
if (length) nippleType += ` ${length}mm`;
|
||||
if (endInfo) nippleType += ` ${endInfo}`;
|
||||
|
||||
displayType = nippleType;
|
||||
} else if (fittingType === 'ELBOW') {
|
||||
let elbowDetails = [];
|
||||
|
||||
// 각도 정보 추출
|
||||
if (fittingSubtype.includes('90DEG') || description.includes('90') || description.includes('90°')) {
|
||||
elbowDetails.push('90°');
|
||||
} else if (fittingSubtype.includes('45DEG') || description.includes('45') || description.includes('45°')) {
|
||||
elbowDetails.push('45°');
|
||||
}
|
||||
|
||||
// 반경 정보 추출 (Long Radius / Short Radius)
|
||||
if (fittingSubtype.includes('LONG_RADIUS') || description.toUpperCase().includes('LR') || description.toUpperCase().includes('LONG RADIUS')) {
|
||||
elbowDetails.push('LR');
|
||||
} else if (fittingSubtype.includes('SHORT_RADIUS') || description.toUpperCase().includes('SR') || description.toUpperCase().includes('SHORT RADIUS')) {
|
||||
elbowDetails.push('SR');
|
||||
}
|
||||
|
||||
// 연결 방식
|
||||
if (description.includes('SW')) {
|
||||
elbowDetails.push('SW');
|
||||
} else if (description.includes('BW')) {
|
||||
elbowDetails.push('BW');
|
||||
}
|
||||
|
||||
// 기본값 설정 (각도가 없으면 90도로 가정)
|
||||
if (!elbowDetails.some(detail => detail.includes('°'))) {
|
||||
elbowDetails.unshift('90°');
|
||||
}
|
||||
|
||||
displayType = `ELBOW ${elbowDetails.join(' ')}`.trim();
|
||||
} else if (fittingType === 'TEE') {
|
||||
// TEE 타입과 연결 방식 상세 표시
|
||||
let teeDetails = [];
|
||||
|
||||
// 등경/축소 타입
|
||||
if (fittingSubtype === 'EQUAL' || description.toUpperCase().includes('TEE EQ')) {
|
||||
teeDetails.push('EQ');
|
||||
} else if (fittingSubtype === 'REDUCING' || description.toUpperCase().includes('TEE RED')) {
|
||||
teeDetails.push('RED');
|
||||
}
|
||||
|
||||
// 연결 방식
|
||||
if (description.includes('SW')) {
|
||||
teeDetails.push('SW');
|
||||
} else if (description.includes('BW')) {
|
||||
teeDetails.push('BW');
|
||||
}
|
||||
|
||||
displayType = `TEE ${teeDetails.join(' ')}`.trim();
|
||||
} else if (fittingType === 'REDUCER') {
|
||||
const reducerType = fittingSubtype === 'CONCENTRIC' ? 'CONC' : fittingSubtype === 'ECCENTRIC' ? 'ECC' : '';
|
||||
const sizes = fittingDetails.reduced_size ? `${material.size_spec}×${fittingDetails.reduced_size}` : material.size_spec;
|
||||
displayType = `RED ${reducerType} ${sizes}`.trim();
|
||||
} else if (fittingType === 'SWAGE') {
|
||||
const swageType = fittingSubtype || '';
|
||||
displayType = `SWAGE ${swageType}`.trim();
|
||||
} else if (fittingType === 'OLET') {
|
||||
const oletSubtype = fittingSubtype || '';
|
||||
let oletDisplayName = '';
|
||||
|
||||
// 백엔드 분류기 결과 우선 사용
|
||||
switch (oletSubtype) {
|
||||
case 'SOCKOLET':
|
||||
oletDisplayName = 'SOCK-O-LET';
|
||||
break;
|
||||
case 'WELDOLET':
|
||||
oletDisplayName = 'WELD-O-LET';
|
||||
break;
|
||||
case 'ELLOLET':
|
||||
oletDisplayName = 'ELL-O-LET';
|
||||
break;
|
||||
case 'THREADOLET':
|
||||
oletDisplayName = 'THREAD-O-LET';
|
||||
break;
|
||||
case 'ELBOLET':
|
||||
oletDisplayName = 'ELB-O-LET';
|
||||
break;
|
||||
case 'NIPOLET':
|
||||
oletDisplayName = 'NIP-O-LET';
|
||||
break;
|
||||
case 'COUPOLET':
|
||||
oletDisplayName = 'COUP-O-LET';
|
||||
break;
|
||||
default:
|
||||
// 백엔드 분류가 없으면 description에서 직접 추출
|
||||
const upperDesc = description.toUpperCase();
|
||||
if (upperDesc.includes('SOCK-O-LET') || upperDesc.includes('SOCKOLET')) {
|
||||
oletDisplayName = 'SOCK-O-LET';
|
||||
} else if (upperDesc.includes('WELD-O-LET') || upperDesc.includes('WELDOLET')) {
|
||||
oletDisplayName = 'WELD-O-LET';
|
||||
} else if (upperDesc.includes('ELL-O-LET') || upperDesc.includes('ELLOLET')) {
|
||||
oletDisplayName = 'ELL-O-LET';
|
||||
} else if (upperDesc.includes('THREAD-O-LET') || upperDesc.includes('THREADOLET')) {
|
||||
oletDisplayName = 'THREAD-O-LET';
|
||||
} else if (upperDesc.includes('ELB-O-LET') || upperDesc.includes('ELBOLET')) {
|
||||
oletDisplayName = 'ELB-O-LET';
|
||||
} else if (upperDesc.includes('NIP-O-LET') || upperDesc.includes('NIPOLET')) {
|
||||
oletDisplayName = 'NIP-O-LET';
|
||||
} else if (upperDesc.includes('COUP-O-LET') || upperDesc.includes('COUPOLET')) {
|
||||
oletDisplayName = 'COUP-O-LET';
|
||||
} else {
|
||||
oletDisplayName = 'OLET';
|
||||
}
|
||||
}
|
||||
|
||||
displayType = oletDisplayName;
|
||||
} else if (!displayType) {
|
||||
displayType = fittingType || 'FITTING';
|
||||
}
|
||||
|
||||
// 압력 등급과 스케줄 추출 (기존 NewMaterialsPage 로직)
|
||||
let pressure = '-';
|
||||
let schedule = '-';
|
||||
|
||||
// 압력 등급 찾기 (3000LB, 6000LB 등) - 소켓웰드 피팅에 특히 중요
|
||||
const pressureMatch = description.match(/(\d+)LB/i);
|
||||
if (pressureMatch) {
|
||||
pressure = `${pressureMatch[1]}LB`;
|
||||
}
|
||||
|
||||
// 소켓웰드 피팅의 경우 압력 등급이 더 중요함
|
||||
if (description.includes('SW') && !pressureMatch) {
|
||||
// SW 피팅인데 압력이 명시되지 않은 경우 기본값 설정
|
||||
if (description.includes('3000') || description.includes('3K')) {
|
||||
pressure = '3000LB';
|
||||
} else if (description.includes('6000') || description.includes('6K')) {
|
||||
pressure = '6000LB';
|
||||
}
|
||||
}
|
||||
|
||||
// 스케줄 표시 (분리 스케줄 지원) - 개선된 로직
|
||||
// 레듀싱 자재인지 확인
|
||||
const isReducingFitting = displayType.includes('REDUCING') || displayType.includes('RED') ||
|
||||
description.toUpperCase().includes('RED') ||
|
||||
description.toUpperCase().includes('REDUCING');
|
||||
|
||||
if (hasDifferentSchedules && mainSchedule && redSchedule) {
|
||||
schedule = `${mainSchedule}×${redSchedule}`;
|
||||
} else if (isReducingFitting && mainSchedule && mainSchedule !== 'UNKNOWN' && mainSchedule !== 'UNCLASSIFIED') {
|
||||
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
|
||||
schedule = `${mainSchedule}×${mainSchedule}`;
|
||||
} else if (mainSchedule && mainSchedule !== 'UNKNOWN' && mainSchedule !== 'UNCLASSIFIED') {
|
||||
schedule = mainSchedule;
|
||||
} else {
|
||||
// Description에서 스케줄 추출 - 더 강력한 패턴 매칭
|
||||
const schedulePatterns = [
|
||||
/SCH\s*(\d+S?)/i, // SCH 40, SCH 80S
|
||||
/SCHEDULE\s*(\d+S?)/i, // SCHEDULE 40
|
||||
/스케줄\s*(\d+S?)/i, // 스케줄 40
|
||||
/(\d+S?)\s*SCH/i, // 40 SCH (역순)
|
||||
/SCH\.?\s*(\d+S?)/i, // SCH.40
|
||||
/SCH\s*(\d+S?)\s*[xX×]\s*SCH\s*(\d+S?)/i // SCH 40 x SCH 80
|
||||
];
|
||||
|
||||
for (const pattern of schedulePatterns) {
|
||||
const match = description.match(pattern);
|
||||
if (match) {
|
||||
if (match.length > 2) {
|
||||
// 분리 스케줄 패턴 (SCH 40 x SCH 80)
|
||||
schedule = `SCH ${match[1]}×SCH ${match[2]}`;
|
||||
} else {
|
||||
const scheduleNum = match[1];
|
||||
if (isReducingFitting) {
|
||||
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
|
||||
schedule = `SCH ${scheduleNum}×SCH ${scheduleNum}`;
|
||||
} else {
|
||||
schedule = `SCH ${scheduleNum}`;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 여전히 찾지 못했다면 더 넓은 패턴 시도
|
||||
if (schedule === '-') {
|
||||
const broadPatterns = [
|
||||
/\b(\d+)\s*LB/i, // 압력 등급에서 유추
|
||||
/\b(40|80|120|160)\b/i, // 일반적인 스케줄 숫자
|
||||
/\b(10|20|30|40|60|80|100|120|140|160)\b/i // 모든 표준 스케줄
|
||||
];
|
||||
|
||||
for (const pattern of broadPatterns) {
|
||||
const match = description.match(pattern);
|
||||
if (match) {
|
||||
const num = match[1];
|
||||
// 압력 등급이 아닌 경우만 스케줄로 간주
|
||||
if (!description.includes(`${num}LB`)) {
|
||||
if (isReducingFitting) {
|
||||
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
|
||||
schedule = `SCH ${num}×SCH ${num}`;
|
||||
} else {
|
||||
schedule = `SCH ${num}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'FITTING',
|
||||
subtype: displayType,
|
||||
size: material.size_spec || '-',
|
||||
pressure: pressure,
|
||||
schedule: schedule,
|
||||
grade: material.full_material_grade || material.material_grade || '-',
|
||||
quantity: Math.round(material.quantity || 0),
|
||||
unit: '개',
|
||||
isFitting: true
|
||||
};
|
||||
};
|
||||
|
||||
// 정렬 처리
|
||||
const handleSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
// 필터링된 및 정렬된 자재 목록
|
||||
const getFilteredAndSortedMaterials = () => {
|
||||
let filtered = materials.filter(material => {
|
||||
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||
if (!filterValue) return true;
|
||||
const info = parseFittingInfo(material);
|
||||
const value = info[key]?.toString().toLowerCase() || '';
|
||||
return value.includes(filterValue.toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
if (sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = parseFittingInfo(a);
|
||||
const bInfo = parseFittingInfo(b);
|
||||
const aValue = aInfo[sortConfig.key] || '';
|
||||
const bValue = bInfo[sortConfig.key] || '';
|
||||
|
||||
if (sortConfig.direction === 'asc') {
|
||||
return aValue > bValue ? 1 : -1;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// 전체 선택/해제 (구매신청된 자재 제외)
|
||||
const handleSelectAll = () => {
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
|
||||
if (selectedMaterials.size === selectableMaterials.length) {
|
||||
setSelectedMaterials(new Set());
|
||||
} else {
|
||||
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택 (구매신청된 자재는 선택 불가)
|
||||
const handleMaterialSelect = (materialId) => {
|
||||
if (purchasedMaterials.has(materialId)) {
|
||||
return; // 구매신청된 자재는 선택 불가
|
||||
}
|
||||
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (newSelected.has(materialId)) {
|
||||
newSelected.delete(materialId);
|
||||
} else {
|
||||
newSelected.add(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExportToExcel = async () => {
|
||||
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||
if (selectedMaterialsData.length === 0) {
|
||||
alert('내보낼 자재를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const excelFileName = `FITTING_Materials_${timestamp}.xlsx`;
|
||||
|
||||
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||
...material,
|
||||
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 피팅 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'FITTING',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||
|
||||
// 2. 구매신청 생성
|
||||
const allMaterialIds = selectedMaterialsData.map(m => m.id);
|
||||
const response = await api.post('/purchase-request/create', {
|
||||
file_id: fileId,
|
||||
job_no: jobNo,
|
||||
category: 'FITTING',
|
||||
material_ids: allMaterialIds,
|
||||
materials_data: dataWithRequirements.map(m => ({
|
||||
material_id: m.id,
|
||||
description: m.original_description,
|
||||
category: m.classified_category,
|
||||
size: m.size_inch || m.size_spec,
|
||||
schedule: m.schedule,
|
||||
material_grade: m.material_grade || m.full_material_grade,
|
||||
quantity: m.quantity,
|
||||
unit: m.unit,
|
||||
user_requirement: userRequirements[m.id] || ''
|
||||
}))
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||
|
||||
// 3. 생성된 엑셀 파일을 서버에 업로드
|
||||
console.log('📤 서버에 엑셀 파일 업로드 중...');
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', response.data.request_id);
|
||||
formData.append('category', 'FITTING');
|
||||
|
||||
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||
|
||||
if (onPurchasedMaterialsUpdate) {
|
||||
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 클라이언트 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||
} catch (error) {
|
||||
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||
// 실패 시에도 클라이언트 다운로드는 진행
|
||||
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||
category: 'FITTING',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||
}
|
||||
|
||||
// 선택 해제
|
||||
setSelectedMaterials(new Set());
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
// 필터 헤더 컴포넌트
|
||||
const FilterableHeader = ({ sortKey, filterKey, children }) => (
|
||||
<div className="filterable-header" style={{ position: 'relative' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span
|
||||
onClick={() => handleSort(sortKey)}
|
||||
style={{ cursor: 'pointer', flex: 1 }}
|
||||
>
|
||||
{children}
|
||||
{sortConfig.key === sortKey && (
|
||||
<span style={{ marginLeft: '4px' }}>
|
||||
{sortConfig.direction === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
fontSize: '12px',
|
||||
color: '#6b7280'
|
||||
}}
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
</div>
|
||||
{showFilterDropdown === filterKey && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
background: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
padding: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
zIndex: 1000,
|
||||
minWidth: '150px'
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Filter ${children}...`}
|
||||
value={columnFilters[filterKey] || ''}
|
||||
onChange={(e) => setColumnFilters({
|
||||
...columnFilters,
|
||||
[filterKey]: e.target.value
|
||||
})}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '32px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
Fitting Materials
|
||||
</h3>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#64748b',
|
||||
margin: 0
|
||||
}}>
|
||||
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #10b981 0%, #059669 100%)' : '#e5e7eb',
|
||||
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Export to Excel ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '600px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<div style={{ minWidth: '1380px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 120px 250px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(() => {
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
|
||||
})()}
|
||||
onChange={handleSelectAll}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<div>Type</div>
|
||||
<div>Size</div>
|
||||
<div>Pressure</div>
|
||||
<div>Schedule</div>
|
||||
<div>Material Grade</div>
|
||||
<div>User Requirements</div>
|
||||
<div>Purchase Quantity</div>
|
||||
<div>Additional Request</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
{filteredMaterials.map((material, index) => {
|
||||
const info = parseFittingInfo(material);
|
||||
const isSelected = selectedMaterials.has(material.id);
|
||||
const isPurchased = purchasedMaterials.has(material.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 120px 250px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleMaterialSelect(material.id)}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||
{info.subtype}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.size}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.pressure}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.schedule}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.grade}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#1f2937',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
{material.user_requirements?.join(', ') || '-'}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
|
||||
{info.quantity} {info.unit}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||
disabled={isPurchased || savingRequest[material.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#64748b'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
No Fitting Materials Found
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||
? 'Try adjusting your filters'
|
||||
: 'No fitting materials available in this BOM'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FittingMaterialsView;
|
||||
722
tkeg/web/src/components/bom/materials/FlangeMaterialsView.jsx
Normal file
722
tkeg/web/src/components/bom/materials/FlangeMaterialsView.jsx
Normal file
@@ -0,0 +1,722 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader, MaterialTable } from '../shared';
|
||||
|
||||
const FlangeMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
fileId,
|
||||
jobNo,
|
||||
user,
|
||||
onNavigate
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedRequestsData = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
}
|
||||
});
|
||||
|
||||
setSavedRequests(savedRequestsData);
|
||||
};
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
// 플랜지 정보 파싱
|
||||
const parseFlangeInfo = (material) => {
|
||||
const description = material.original_description || '';
|
||||
const flangeDetails = material.flange_details || {};
|
||||
|
||||
const flangeTypeMap = {
|
||||
'WN': 'WELD NECK FLANGE',
|
||||
'WELD_NECK': 'WELD NECK FLANGE',
|
||||
'SO': 'SLIP ON FLANGE',
|
||||
'SLIP_ON': 'SLIP ON FLANGE',
|
||||
'SW': 'SOCKET WELD FLANGE',
|
||||
'SOCKET_WELD': 'SOCKET WELD FLANGE',
|
||||
'THREADED': 'THREADED FLANGE',
|
||||
'THD': 'THREADED FLANGE',
|
||||
'BLIND': 'BLIND FLANGE',
|
||||
'LAP_JOINT': 'LAP JOINT FLANGE',
|
||||
'LJ': 'LAP JOINT FLANGE',
|
||||
'REDUCING': 'REDUCING FLANGE',
|
||||
'ORIFICE': 'ORIFICE FLANGE',
|
||||
'SPECTACLE': 'SPECTACLE BLIND',
|
||||
'SPECTACLE_BLIND': 'SPECTACLE BLIND',
|
||||
'PADDLE': 'PADDLE BLIND',
|
||||
'PADDLE_BLIND': 'PADDLE BLIND',
|
||||
'SPACER': 'SPACER',
|
||||
'SWIVEL': 'SWIVEL FLANGE',
|
||||
'DRIP_RING': 'DRIP RING',
|
||||
'NOZZLE': 'NOZZLE FLANGE'
|
||||
};
|
||||
|
||||
const facingTypeMap = {
|
||||
'RF': 'RAISED FACE',
|
||||
'RAISED_FACE': 'RAISED FACE',
|
||||
'FF': 'FLAT FACE',
|
||||
'FLAT_FACE': 'FLAT FACE',
|
||||
'RTJ': 'RING TYPE JOINT',
|
||||
'RING_TYPE_JOINT': 'RING TYPE JOINT'
|
||||
};
|
||||
|
||||
const rawFlangeType = flangeDetails.flange_type || '';
|
||||
const rawFacingType = flangeDetails.facing_type || '';
|
||||
|
||||
|
||||
// rawFlangeType에서 facing 정보 분리 (예: "WN RF" -> "WN")
|
||||
let cleanFlangeType = rawFlangeType;
|
||||
let extractedFacing = rawFacingType;
|
||||
|
||||
// facing 정보가 flange_type에 포함된 경우 분리
|
||||
if (rawFlangeType.includes(' RF')) {
|
||||
cleanFlangeType = rawFlangeType.replace(' RF', '').trim();
|
||||
if (!extractedFacing || extractedFacing === '-') extractedFacing = 'RAISED_FACE';
|
||||
} else if (rawFlangeType.includes(' FF')) {
|
||||
cleanFlangeType = rawFlangeType.replace(' FF', '').trim();
|
||||
if (!extractedFacing || extractedFacing === '-') extractedFacing = 'FLAT_FACE';
|
||||
} else if (rawFlangeType.includes(' RTJ')) {
|
||||
cleanFlangeType = rawFlangeType.replace(' RTJ', '').trim();
|
||||
if (!extractedFacing || extractedFacing === '-') extractedFacing = 'RING_TYPE_JOINT';
|
||||
}
|
||||
|
||||
let displayType = flangeTypeMap[cleanFlangeType] || '-';
|
||||
let facingType = facingTypeMap[extractedFacing] || '-';
|
||||
|
||||
// Description에서 추출
|
||||
if (displayType === '-') {
|
||||
const desc = description.toUpperCase();
|
||||
if (desc.includes('ORIFICE')) {
|
||||
displayType = 'ORIFICE FLANGE';
|
||||
} else if (desc.includes('SPECTACLE')) {
|
||||
displayType = 'SPECTACLE BLIND';
|
||||
} else if (desc.includes('PADDLE')) {
|
||||
displayType = 'PADDLE BLIND';
|
||||
} else if (desc.includes('SPACER')) {
|
||||
displayType = 'SPACER';
|
||||
} else if (desc.includes('REDUCING') || desc.includes('RED')) {
|
||||
displayType = 'REDUCING FLANGE';
|
||||
} else if (desc.includes('BLIND')) {
|
||||
displayType = 'BLIND FLANGE';
|
||||
} else if (desc.includes('WN RF') || desc.includes('WN-RF')) {
|
||||
displayType = 'WELD NECK FLANGE';
|
||||
if (facingType === '-') facingType = 'RAISED FACE';
|
||||
} else if (desc.includes('WN FF') || desc.includes('WN-FF')) {
|
||||
displayType = 'WELD NECK FLANGE';
|
||||
if (facingType === '-') facingType = 'FLAT FACE';
|
||||
} else if (desc.includes('WN RTJ') || desc.includes('WN-RTJ')) {
|
||||
displayType = 'WELD NECK FLANGE';
|
||||
if (facingType === '-') facingType = 'RING TYPE JOINT';
|
||||
} else if (desc.includes('WN')) {
|
||||
displayType = 'WELD NECK FLANGE';
|
||||
} else if (desc.includes('SO RF') || desc.includes('SO-RF')) {
|
||||
displayType = 'SLIP ON FLANGE';
|
||||
if (facingType === '-') facingType = 'RAISED FACE';
|
||||
} else if (desc.includes('SO FF') || desc.includes('SO-FF')) {
|
||||
displayType = 'SLIP ON FLANGE';
|
||||
if (facingType === '-') facingType = 'FLAT FACE';
|
||||
} else if (desc.includes('SO')) {
|
||||
displayType = 'SLIP ON FLANGE';
|
||||
} else if (desc.includes('SW')) {
|
||||
displayType = 'SOCKET WELD FLANGE';
|
||||
} else {
|
||||
displayType = 'FLANGE';
|
||||
}
|
||||
}
|
||||
|
||||
if (facingType === '-') {
|
||||
const desc = description.toUpperCase();
|
||||
if (desc.includes('RF')) {
|
||||
facingType = 'RAISED FACE';
|
||||
} else if (desc.includes('FF')) {
|
||||
facingType = 'FLAT FACE';
|
||||
} else if (desc.includes('RTJ')) {
|
||||
facingType = 'RING TYPE JOINT';
|
||||
}
|
||||
}
|
||||
|
||||
// 원본 설명에서 스케줄 추출
|
||||
let schedule = '-';
|
||||
const upperDesc = description.toUpperCase();
|
||||
|
||||
// SCH 40, SCH 80 등의 패턴 찾기
|
||||
if (upperDesc.includes('SCH')) {
|
||||
const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i);
|
||||
if (schMatch && schMatch[1]) {
|
||||
schedule = `SCH ${schMatch[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 압력 등급 추출
|
||||
let pressure = '-';
|
||||
const pressureMatch = description.match(/(\d+)LB/i);
|
||||
if (pressureMatch) {
|
||||
pressure = `${pressureMatch[1]}LB`;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'FLANGE',
|
||||
subtype: displayType, // 풀네임 플랜지 타입
|
||||
facing: facingType, // 새로 추가: 끝단처리 정보
|
||||
size: material.size_spec || '-',
|
||||
pressure: flangeDetails.pressure_rating || pressure,
|
||||
schedule: schedule,
|
||||
grade: material.full_material_grade || material.material_grade || '-',
|
||||
quantity: Math.round(material.quantity || 0),
|
||||
unit: '개',
|
||||
isFlange: true // 플랜지 구분용 플래그
|
||||
};
|
||||
};
|
||||
|
||||
// 정렬 처리
|
||||
const handleSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
// 필터링된 및 정렬된 자재 목록
|
||||
const getFilteredAndSortedMaterials = () => {
|
||||
let filtered = materials.filter(material => {
|
||||
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||
if (!filterValue) return true;
|
||||
const info = parseFlangeInfo(material);
|
||||
const value = info[key]?.toString().toLowerCase() || '';
|
||||
return value.includes(filterValue.toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
if (sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = parseFlangeInfo(a);
|
||||
const bInfo = parseFlangeInfo(b);
|
||||
const aValue = aInfo[sortConfig.key] || '';
|
||||
const bValue = bInfo[sortConfig.key] || '';
|
||||
|
||||
if (sortConfig.direction === 'asc') {
|
||||
return aValue > bValue ? 1 : -1;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleSelectAll = () => {
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
if (selectedMaterials.size === filteredMaterials.length) {
|
||||
setSelectedMaterials(new Set());
|
||||
} else {
|
||||
setSelectedMaterials(new Set(filteredMaterials.map(m => m.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택
|
||||
const handleMaterialSelect = (materialId) => {
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (newSelected.has(materialId)) {
|
||||
newSelected.delete(materialId);
|
||||
} else {
|
||||
newSelected.add(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExportToExcel = async () => {
|
||||
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||
if (selectedMaterialsData.length === 0) {
|
||||
alert('내보낼 자재를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const excelFileName = `FLANGE_Materials_${timestamp}.xlsx`;
|
||||
|
||||
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||
...material,
|
||||
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'FLANGE',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||
|
||||
// 2. 구매신청 생성
|
||||
const allMaterialIds = selectedMaterialsData.map(m => m.id);
|
||||
const response = await api.post('/purchase-request/create', {
|
||||
file_id: fileId,
|
||||
job_no: jobNo,
|
||||
category: 'FLANGE',
|
||||
material_ids: allMaterialIds,
|
||||
materials_data: dataWithRequirements.map(m => ({
|
||||
material_id: m.id,
|
||||
description: m.original_description,
|
||||
category: m.classified_category,
|
||||
size: m.size_inch || m.size_spec,
|
||||
schedule: m.schedule,
|
||||
material_grade: m.material_grade || m.full_material_grade,
|
||||
quantity: m.quantity,
|
||||
unit: m.unit,
|
||||
user_requirement: userRequirements[m.id] || ''
|
||||
}))
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||
|
||||
// 3. 생성된 엑셀 파일을 서버에 업로드
|
||||
console.log('📤 서버에 엑셀 파일 업로드 중...');
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', response.data.request_id);
|
||||
formData.append('category', 'FLANGE');
|
||||
|
||||
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||
|
||||
if (onPurchasedMaterialsUpdate) {
|
||||
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 클라이언트 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||
} catch (error) {
|
||||
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||
// 실패 시에도 클라이언트 다운로드는 진행
|
||||
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||
category: 'FLANGE',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||
}
|
||||
|
||||
setSelectedMaterials(new Set());
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
// 필터 헤더 컴포넌트
|
||||
const FilterableHeader = ({ sortKey, filterKey, children }) => (
|
||||
<div className="filterable-header" style={{ position: 'relative' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span
|
||||
onClick={() => handleSort(sortKey)}
|
||||
style={{ cursor: 'pointer', flex: 1 }}
|
||||
>
|
||||
{children}
|
||||
{sortConfig.key === sortKey && (
|
||||
<span style={{ marginLeft: '4px' }}>
|
||||
{sortConfig.direction === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
fontSize: '12px',
|
||||
color: '#6b7280'
|
||||
}}
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
</div>
|
||||
{showFilterDropdown === filterKey && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
background: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
padding: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
zIndex: 1000,
|
||||
minWidth: '150px'
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Filter ${children}...`}
|
||||
value={columnFilters[filterKey] || ''}
|
||||
onChange={(e) => setColumnFilters({
|
||||
...columnFilters,
|
||||
[filterKey]: e.target.value
|
||||
})}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '32px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
Flange Materials
|
||||
</h3>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#64748b',
|
||||
margin: 0
|
||||
}}>
|
||||
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' : '#e5e7eb',
|
||||
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Export to Excel ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '600px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<div style={{ minWidth: '1400px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 250px 150px 120px 120px 100px 150px 180px 250px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(() => {
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
|
||||
})()}
|
||||
onChange={handleSelectAll}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader sortKey="subtype" filterKey="subtype">Type</FilterableHeader>
|
||||
<FilterableHeader sortKey="facing" filterKey="facing">Facing</FilterableHeader>
|
||||
<FilterableHeader sortKey="size" filterKey="size">Size</FilterableHeader>
|
||||
<FilterableHeader sortKey="pressure" filterKey="pressure">Pressure</FilterableHeader>
|
||||
<FilterableHeader sortKey="schedule" filterKey="schedule">Schedule</FilterableHeader>
|
||||
<FilterableHeader sortKey="grade" filterKey="grade">Material Grade</FilterableHeader>
|
||||
<div>Purchase Quantity</div>
|
||||
<div>Additional Request</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
<div>
|
||||
{filteredMaterials.map((material, index) => {
|
||||
const info = parseFlangeInfo(material);
|
||||
const isSelected = selectedMaterials.has(material.id);
|
||||
const isPurchased = purchasedMaterials.has(material.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 250px 150px 120px 120px 100px 150px 180px 250px',
|
||||
gap: '12px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleMaterialSelect(material.id)}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500', textAlign: 'center' }}>
|
||||
{info.subtype}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.facing}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.size}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.pressure}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.schedule}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.grade}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'center' }}>
|
||||
{info.quantity} {info.unit}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||
disabled={isPurchased || savingRequest[material.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#64748b'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
No Flange Materials Found
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||
? 'Try adjusting your filters'
|
||||
: 'No flange materials available in this BOM'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlangeMaterialsView;
|
||||
693
tkeg/web/src/components/bom/materials/GasketMaterialsView.jsx
Normal file
693
tkeg/web/src/components/bom/materials/GasketMaterialsView.jsx
Normal file
@@ -0,0 +1,693 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader } from '../shared';
|
||||
|
||||
const GasketMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
fileId,
|
||||
jobNo,
|
||||
user
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedRequestsData = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
}
|
||||
});
|
||||
|
||||
setSavedRequests(savedRequestsData);
|
||||
};
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
const parseGasketInfo = (material) => {
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
const purchaseQty = Math.ceil(qty * 1.05 / 5) * 5; // 5% 여유율 + 5의 배수
|
||||
|
||||
const description = material.original_description || '';
|
||||
|
||||
// 가스켓 타입 풀네임 매핑
|
||||
const gasketTypeMap = {
|
||||
'SWG': 'SPIRAL WOUND GASKET',
|
||||
'RTJ': 'RING TYPE JOINT',
|
||||
'FF': 'FULL FACE GASKET',
|
||||
'RF': 'RAISED FACE GASKET',
|
||||
'SHEET': 'SHEET GASKET',
|
||||
'O-RING': 'O-RING GASKET'
|
||||
};
|
||||
|
||||
// 타입 추출 및 풀네임 변환
|
||||
let gasketType = '-';
|
||||
const typeMatch = description.match(/\b(SWG|RTJ|FF|RF|SHEET|O-RING)\b/i);
|
||||
if (typeMatch) {
|
||||
const shortType = typeMatch[1].toUpperCase();
|
||||
gasketType = gasketTypeMap[shortType] || shortType;
|
||||
}
|
||||
|
||||
// 크기 정보 추출 (예: 1 1/2")
|
||||
let size = material.size_spec || material.size_inch || '-';
|
||||
if (size === '-') {
|
||||
const sizeMatch = description.match(/(\d+(?:\s+\d+\/\d+)?(?:\.\d+)?)\s*"/);
|
||||
if (sizeMatch) {
|
||||
size = sizeMatch[1] + '"';
|
||||
}
|
||||
}
|
||||
|
||||
// 압력등급 추출
|
||||
let pressure = '-';
|
||||
const pressureMatch = description.match(/(\d+LB)/i);
|
||||
if (pressureMatch) {
|
||||
pressure = pressureMatch[1];
|
||||
}
|
||||
|
||||
// 구조 정보 추출 (H/F/I/O)
|
||||
let structure = '-';
|
||||
if (description.includes('H/F/I/O')) {
|
||||
structure = 'H/F/I/O';
|
||||
}
|
||||
|
||||
// 재질 상세 정보 추출 (SS304/GRAPHITE/SS304/SS304)
|
||||
let material_detail = '-';
|
||||
const materialMatch = description.match(/H\/F\/I\/O\s+([^,]+)/);
|
||||
if (materialMatch) {
|
||||
material_detail = materialMatch[1].trim();
|
||||
// 두께 정보 제거
|
||||
material_detail = material_detail.replace(/,?\s*\d+(?:\.\d+)?mm$/, '').trim();
|
||||
}
|
||||
|
||||
// 두께 정보 추출
|
||||
let thickness = '-';
|
||||
const thicknessMatch = description.match(/(\d+(?:\.\d+)?)\s*mm/i);
|
||||
if (thicknessMatch) {
|
||||
thickness = thicknessMatch[1] + 'mm';
|
||||
}
|
||||
|
||||
return {
|
||||
type: gasketType, // 풀네임으로 표시 (SPIRAL WOUND GASKET)
|
||||
size: size,
|
||||
pressure: pressure,
|
||||
structure: structure, // H/F/I/O
|
||||
material: material_detail, // SS304/GRAPHITE/SS304/SS304
|
||||
thickness: thickness,
|
||||
userRequirements: material.user_requirements?.join(', ') || '-',
|
||||
purchaseQuantity: purchaseQty,
|
||||
isGasket: true
|
||||
};
|
||||
};
|
||||
|
||||
// 정렬 처리
|
||||
const handleSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
// 필터링된 및 정렬된 자재 목록
|
||||
const getFilteredAndSortedMaterials = () => {
|
||||
let filtered = materials.filter(material => {
|
||||
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||
if (!filterValue) return true;
|
||||
const info = parseGasketInfo(material);
|
||||
const value = info[key]?.toString().toLowerCase() || '';
|
||||
return value.includes(filterValue.toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
if (sortConfig && sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = parseGasketInfo(a);
|
||||
const bInfo = parseGasketInfo(b);
|
||||
|
||||
if (!aInfo || !bInfo) return 0;
|
||||
|
||||
const aValue = aInfo[sortConfig.key];
|
||||
const bValue = bInfo[sortConfig.key];
|
||||
|
||||
// 값이 없는 경우 처리
|
||||
if (aValue === undefined && bValue === undefined) return 0;
|
||||
if (aValue === undefined) return 1;
|
||||
if (bValue === undefined) return -1;
|
||||
|
||||
// 숫자인 경우 숫자로 비교
|
||||
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
|
||||
}
|
||||
|
||||
// 문자열로 비교
|
||||
const aStr = String(aValue).toLowerCase();
|
||||
const bStr = String(bValue).toLowerCase();
|
||||
|
||||
if (sortConfig.direction === 'asc') {
|
||||
return aStr.localeCompare(bStr);
|
||||
} else {
|
||||
return bStr.localeCompare(aStr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// 전체 선택/해제 (구매신청된 자재 제외)
|
||||
const handleSelectAll = () => {
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
|
||||
if (selectedMaterials.size === selectableMaterials.length) {
|
||||
setSelectedMaterials(new Set());
|
||||
} else {
|
||||
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택 (구매신청된 자재는 선택 불가)
|
||||
const handleMaterialSelect = (materialId) => {
|
||||
if (purchasedMaterials.has(materialId)) {
|
||||
return; // 구매신청된 자재는 선택 불가
|
||||
}
|
||||
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (newSelected.has(materialId)) {
|
||||
newSelected.delete(materialId);
|
||||
} else {
|
||||
newSelected.add(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExportToExcel = async () => {
|
||||
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||
if (selectedMaterialsData.length === 0) {
|
||||
alert('내보낼 자재를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const excelFileName = `GASKET_Materials_${timestamp}.xlsx`;
|
||||
|
||||
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||
...material,
|
||||
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 가스켓 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'GASKET',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||
|
||||
// 2. 구매신청 생성
|
||||
const allMaterialIds = selectedMaterialsData.map(m => m.id);
|
||||
const response = await api.post('/purchase-request/create', {
|
||||
file_id: fileId,
|
||||
job_no: jobNo,
|
||||
category: 'GASKET',
|
||||
material_ids: allMaterialIds,
|
||||
materials_data: dataWithRequirements.map(m => ({
|
||||
material_id: m.id,
|
||||
description: m.original_description,
|
||||
category: m.classified_category,
|
||||
size: m.size_inch || m.size_spec,
|
||||
schedule: m.schedule,
|
||||
material_grade: m.material_grade || m.full_material_grade,
|
||||
quantity: m.quantity,
|
||||
unit: m.unit,
|
||||
user_requirement: userRequirements[m.id] || ''
|
||||
}))
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||
|
||||
// 3. 생성된 엑셀 파일을 서버에 업로드
|
||||
console.log('📤 서버에 엑셀 파일 업로드 중...');
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', response.data.request_id);
|
||||
formData.append('category', 'GASKET');
|
||||
|
||||
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||
|
||||
if (onPurchasedMaterialsUpdate) {
|
||||
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 클라이언트 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||
} catch (error) {
|
||||
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||
// 실패 시에도 클라이언트 다운로드는 진행
|
||||
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||
category: 'GASKET',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
return (
|
||||
<div style={{ padding: '32px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
Gasket Materials
|
||||
</h3>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#64748b',
|
||||
margin: 0
|
||||
}}>
|
||||
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)' : '#e5e7eb',
|
||||
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Export to Excel ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '600px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<div style={{ minWidth: '1400px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 250px 120px 100px 120px 200px 100px 180px 200px 120px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(() => {
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
|
||||
})()}
|
||||
onChange={handleSelectAll}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader
|
||||
sortKey="type"
|
||||
filterKey="type"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Type
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="size"
|
||||
filterKey="size"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Size
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="pressure"
|
||||
filterKey="pressure"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Pressure
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="structure"
|
||||
filterKey="structure"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Structure
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="material"
|
||||
filterKey="material"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Material
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="thickness"
|
||||
filterKey="thickness"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Thickness
|
||||
</FilterableHeader>
|
||||
<div>User Requirements</div>
|
||||
<div>Additional Request</div>
|
||||
<FilterableHeader
|
||||
sortKey="purchaseQuantity"
|
||||
filterKey="purchaseQuantity"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Purchase Quantity
|
||||
</FilterableHeader>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
{filteredMaterials.map((material, index) => {
|
||||
const info = parseGasketInfo(material);
|
||||
const isSelected = selectedMaterials.has(material.id);
|
||||
const isPurchased = purchasedMaterials.has(material.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 250px 120px 100px 120px 200px 100px 180px 200px 120px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleMaterialSelect(material.id)}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500', textAlign: 'center' }}>
|
||||
{info.type}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.size}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.pressure}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.structure}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.material}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.thickness}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#1f2937',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
{info.userRequirements}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||
disabled={isPurchased || savingRequest[material.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center', fontWeight: '500' }}>
|
||||
{info.purchaseQuantity.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#64748b'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
No Gasket Materials Found
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||
? 'Try adjusting your filters'
|
||||
: 'No gasket materials available in this BOM'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GasketMaterialsView;
|
||||
792
tkeg/web/src/components/bom/materials/PipeMaterialsView.jsx
Normal file
792
tkeg/web/src/components/bom/materials/PipeMaterialsView.jsx
Normal file
@@ -0,0 +1,792 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader, MaterialTable } from '../shared';
|
||||
|
||||
const PipeMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
fileId,
|
||||
jobNo,
|
||||
user,
|
||||
onNavigate
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [brandInputs, setBrandInputs] = useState({}); // 브랜드 입력 상태
|
||||
const [savingBrand, setSavingBrand] = useState({}); // 브랜드 저장 중 상태
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
const [savedBrands, setSavedBrands] = useState({}); // 저장된 브랜드 상태
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingBrand, setEditingBrand] = useState({}); // 브랜드 편집 모드
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedBrandsData = {};
|
||||
const savedRequestsData = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
// 백엔드에서 가져온 데이터가 있으면 저장된 상태로 설정
|
||||
if (material.brand && material.brand.trim()) {
|
||||
savedBrandsData[material.id] = material.brand.trim();
|
||||
}
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
}
|
||||
});
|
||||
|
||||
setSavedBrands(savedBrandsData);
|
||||
setSavedRequests(savedRequestsData);
|
||||
};
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
setSavedBrands({});
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
// 브랜드 저장 함수
|
||||
const handleSaveBrand = async (materialId, brand) => {
|
||||
if (!brand.trim()) return;
|
||||
|
||||
setSavingBrand(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/brand`, { brand: brand.trim() });
|
||||
setSavedBrands(prev => ({ ...prev, [materialId]: brand.trim() }));
|
||||
setEditingBrand(prev => ({ ...prev, [materialId]: false }));
|
||||
setBrandInputs(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { brand: brand.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('브랜드 저장 실패:', error);
|
||||
alert('브랜드 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingBrand(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 브랜드 편집 시작
|
||||
const handleEditBrand = (materialId, currentBrand) => {
|
||||
setEditingBrand(prev => ({ ...prev, [materialId]: true }));
|
||||
setBrandInputs(prev => ({ ...prev, [materialId]: currentBrand || '' }));
|
||||
};
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
// 파이프 구매 수량 계산 (백엔드 그룹화 데이터 활용)
|
||||
const calculatePipePurchase = (material) => {
|
||||
const pipeDetails = material.pipe_details || {};
|
||||
|
||||
// 백엔드에서 이미 그룹화된 데이터 사용
|
||||
let pipeCount = 1; // 기본값
|
||||
let totalBomLengthMm = 0;
|
||||
|
||||
if (pipeDetails.pipe_count && pipeDetails.total_length_mm) {
|
||||
// 백엔드에서 그룹화된 데이터 사용
|
||||
pipeCount = pipeDetails.pipe_count; // 실제 단관 개수
|
||||
totalBomLengthMm = pipeDetails.total_length_mm; // 이미 합산된 총 길이
|
||||
} else {
|
||||
// 개별 파이프 데이터인 경우
|
||||
pipeCount = material.quantity || 1;
|
||||
|
||||
// 길이 정보 우선순위: length_mm > length > pipe_details.length_mm
|
||||
let singlePipeLengthMm = 0;
|
||||
if (material.length_mm) {
|
||||
singlePipeLengthMm = material.length_mm;
|
||||
} else if (material.length) {
|
||||
singlePipeLengthMm = material.length * 1000; // m를 mm로 변환
|
||||
} else if (pipeDetails.length_mm) {
|
||||
singlePipeLengthMm = pipeDetails.length_mm;
|
||||
}
|
||||
|
||||
totalBomLengthMm = singlePipeLengthMm * pipeCount;
|
||||
}
|
||||
|
||||
// 여유분 포함 계산: 각 단관당 2mm 여유분 추가
|
||||
const allowancePerPipe = 2; // mm
|
||||
const totalAllowanceMm = allowancePerPipe * pipeCount;
|
||||
const totalLengthWithAllowance = totalBomLengthMm + totalAllowanceMm; // mm
|
||||
|
||||
// 6,000mm(6m) 표준 길이로 필요한 본수 계산 (올림)
|
||||
const standardLengthMm = 6000; // mm
|
||||
const requiredStandardPipes = Math.ceil(totalLengthWithAllowance / standardLengthMm);
|
||||
|
||||
return {
|
||||
pipeCount, // 단관 개수
|
||||
totalBomLengthMm, // 총 BOM 길이 (mm)
|
||||
totalLengthWithAllowance, // 여유분 포함 총 길이 (mm)
|
||||
totalLengthM: totalLengthWithAllowance / 1000, // 총 길이 (m)
|
||||
requiredStandardPipes, // 필요한 표준 파이프 본수
|
||||
standardLengthMm,
|
||||
allowancePerPipe,
|
||||
totalAllowanceMm,
|
||||
// 디버깅용 정보
|
||||
isGrouped: !!(pipeDetails.pipe_count && pipeDetails.total_length_mm)
|
||||
};
|
||||
};
|
||||
|
||||
// 파이프 정보 파싱 (개선된 로직)
|
||||
const parsePipeInfo = (material) => {
|
||||
const calc = calculatePipePurchase(material);
|
||||
const pipeDetails = material.pipe_details || {};
|
||||
|
||||
// User 요구사항 추출 (분류기에서 제공된 정보)
|
||||
const userRequirements = material.user_requirements || [];
|
||||
const userReqText = userRequirements.length > 0 ? userRequirements.join(', ') : '-';
|
||||
|
||||
return {
|
||||
// Type 컬럼 제거 (모두 PIPE로 동일)
|
||||
type: pipeDetails.manufacturing_method || 'SMLS', // Subtype을 Type으로 변경
|
||||
size: material.size_spec || '-',
|
||||
schedule: pipeDetails.schedule || material.schedule || '-',
|
||||
grade: material.full_material_grade || material.material_grade || '-',
|
||||
userRequirements: userReqText, // User 요구사항
|
||||
length: calc.totalLengthWithAllowance, // 여유분 포함 총 길이 (mm)
|
||||
quantity: calc.pipeCount, // 단관 개수
|
||||
unit: `${calc.requiredStandardPipes}본`, // 6m 표준 파이프 필요 본수
|
||||
details: calc,
|
||||
isPipe: true
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
// 정렬 처리
|
||||
const handleSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
// 필터링된 및 정렬된 자재 목록
|
||||
const getFilteredAndSortedMaterials = () => {
|
||||
let filtered = materials.filter(material => {
|
||||
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||
if (!filterValue) return true;
|
||||
const info = parsePipeInfo(material);
|
||||
const value = info[key]?.toString().toLowerCase() || '';
|
||||
return value.includes(filterValue.toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
if (sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = parsePipeInfo(a);
|
||||
const bInfo = parsePipeInfo(b);
|
||||
const aValue = aInfo[sortConfig.key] || '';
|
||||
const bValue = bInfo[sortConfig.key] || '';
|
||||
|
||||
if (sortConfig.direction === 'asc') {
|
||||
return aValue > bValue ? 1 : -1;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// 전체 선택/해제 (구매된 자재 제외)
|
||||
const handleSelectAll = () => {
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
|
||||
if (selectedMaterials.size === selectableMaterials.length) {
|
||||
setSelectedMaterials(new Set());
|
||||
} else {
|
||||
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택 (구매된 자재는 선택 불가)
|
||||
const handleMaterialSelect = (materialId) => {
|
||||
if (purchasedMaterials.has(materialId)) {
|
||||
return; // 구매된 자재는 선택 불가
|
||||
}
|
||||
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (newSelected.has(materialId)) {
|
||||
newSelected.delete(materialId);
|
||||
} else {
|
||||
newSelected.add(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExportToExcel = async () => {
|
||||
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||
if (selectedMaterialsData.length === 0) {
|
||||
alert('내보낼 자재를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const excelFileName = `PIPE_Materials_${timestamp}.xlsx`;
|
||||
|
||||
// 사용자 요구사항 포함
|
||||
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||
...material,
|
||||
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 파이프 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'PIPE',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||
|
||||
// 2. 구매신청 생성
|
||||
const allMaterialIds = selectedMaterialsData.map(m => m.id);
|
||||
const response = await api.post('/purchase-request/create', {
|
||||
file_id: fileId,
|
||||
job_no: jobNo,
|
||||
category: 'PIPE',
|
||||
material_ids: allMaterialIds,
|
||||
materials_data: dataWithRequirements.map(m => ({
|
||||
material_id: m.id,
|
||||
description: m.original_description,
|
||||
category: m.classified_category,
|
||||
size: m.size_inch || m.size_spec,
|
||||
schedule: m.schedule,
|
||||
material_grade: m.material_grade || m.full_material_grade,
|
||||
quantity: m.quantity,
|
||||
unit: m.unit,
|
||||
user_requirement: userRequirements[m.id] || ''
|
||||
}))
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||
|
||||
// 3. 생성된 엑셀 파일을 서버에 업로드
|
||||
console.log('📤 서버에 엑셀 파일 업로드 중...');
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', response.data.request_id);
|
||||
formData.append('category', 'PIPE');
|
||||
|
||||
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||
|
||||
if (onPurchasedMaterialsUpdate) {
|
||||
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 클라이언트 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||
} catch (error) {
|
||||
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||
// 실패 시에도 클라이언트 다운로드는 진행
|
||||
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||
category: 'PIPE',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||
}
|
||||
|
||||
// 선택 해제
|
||||
setSelectedMaterials(new Set());
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
// 필터 헤더 컴포넌트
|
||||
const FilterableHeader = ({ sortKey, filterKey, children }) => (
|
||||
<div className="filterable-header" style={{ position: 'relative' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span
|
||||
onClick={() => handleSort(sortKey)}
|
||||
style={{ cursor: 'pointer', flex: 1 }}
|
||||
>
|
||||
{children}
|
||||
{sortConfig.key === sortKey && (
|
||||
<span style={{ marginLeft: '4px' }}>
|
||||
{sortConfig.direction === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
fontSize: '12px',
|
||||
color: '#6b7280'
|
||||
}}
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
</div>
|
||||
{showFilterDropdown === filterKey && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
background: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
padding: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
zIndex: 1000,
|
||||
minWidth: '150px'
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Filter ${children}...`}
|
||||
value={columnFilters[filterKey] || ''}
|
||||
onChange={(e) => setColumnFilters({
|
||||
...columnFilters,
|
||||
[filterKey]: e.target.value
|
||||
})}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '32px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
Pipe Materials
|
||||
</h3>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#64748b',
|
||||
margin: 0
|
||||
}}>
|
||||
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)' : '#e5e7eb',
|
||||
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Export to Excel ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
maxHeight: '600px'
|
||||
}}>
|
||||
{/* 테이블 내용 - 헤더와 본문이 함께 스크롤 */}
|
||||
<div style={{
|
||||
minWidth: '1200px'
|
||||
}}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 120px 120px 120px 150px 200px 120px 100px 120px 300px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(() => {
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
|
||||
})()}
|
||||
onChange={handleSelectAll}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader
|
||||
sortKey="type"
|
||||
filterKey="type"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Type
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="size"
|
||||
filterKey="size"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Size
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="schedule"
|
||||
filterKey="schedule"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Schedule
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="grade"
|
||||
filterKey="grade"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Material Grade
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="userRequirements"
|
||||
filterKey="userRequirements"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
User Requirements
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="length"
|
||||
filterKey="length"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Length (MM)
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="quantity"
|
||||
filterKey="quantity"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Quantity (EA)
|
||||
</FilterableHeader>
|
||||
<div>Purchase Unit</div>
|
||||
<div>Additional Request</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
<div>
|
||||
{filteredMaterials.map((material, index) => {
|
||||
const info = parsePipeInfo(material);
|
||||
const isSelected = selectedMaterials.has(material.id);
|
||||
const isPurchased = purchasedMaterials.has(material.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 120px 120px 120px 150px 200px 120px 100px 120px 300px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleMaterialSelect(material.id)}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||
{info.type}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.size}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.schedule}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.grade}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#1f2937',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
{info.userRequirements}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{Math.round(info.length).toLocaleString()}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
|
||||
{info.quantity}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>
|
||||
{info.unit}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||
disabled={isPurchased || savingRequest[material.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#64748b'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
No Pipe Materials Found
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||
? 'Try adjusting your filters'
|
||||
: 'No pipe materials available in this BOM'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PipeMaterialsView;
|
||||
628
tkeg/web/src/components/bom/materials/SpecialMaterialsView.jsx
Normal file
628
tkeg/web/src/components/bom/materials/SpecialMaterialsView.jsx
Normal file
@@ -0,0 +1,628 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader } from '../shared';
|
||||
|
||||
const SpecialMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
jobNo,
|
||||
fileId,
|
||||
user,
|
||||
onNavigate
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedRequestsData = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
}
|
||||
});
|
||||
|
||||
setSavedRequests(savedRequestsData);
|
||||
};
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
// SPECIAL 자재 정보 파싱
|
||||
const parseSpecialInfo = (material) => {
|
||||
const description = material.original_description || '';
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
|
||||
// Type 추출 (큰 범주: 우선순위 기반 분류)
|
||||
let type = 'SPECIAL';
|
||||
const descUpper = description.toUpperCase();
|
||||
|
||||
// 우선순위 1: 주요 장비 타입 (OIL PUMP, COMPRESSOR 등이 FLG보다 우선)
|
||||
if (descUpper.includes('OIL PUMP') || (descUpper.includes('PUMP') && !descUpper.includes('FITTING'))) {
|
||||
type = 'OIL PUMP';
|
||||
} else if (descUpper.includes('COMPRESSOR')) {
|
||||
type = 'COMPRESSOR';
|
||||
} else if (descUpper.includes('VALVE') && !descUpper.includes('FLG')) {
|
||||
type = 'VALVE';
|
||||
}
|
||||
// 우선순위 2: 구조물/부품 타입
|
||||
else if (descUpper.includes('FLG') || descUpper.includes('FLANGE')) {
|
||||
// FLG가 있어도 주요 장비가 함께 있으면 장비 타입 우선
|
||||
if (descUpper.includes('OIL PUMP') || descUpper.includes('COMPRESSOR')) {
|
||||
if (descUpper.includes('OIL PUMP')) {
|
||||
type = 'OIL PUMP';
|
||||
} else if (descUpper.includes('COMPRESSOR')) {
|
||||
type = 'COMPRESSOR';
|
||||
}
|
||||
} else {
|
||||
type = 'FLANGE';
|
||||
}
|
||||
} else if (descUpper.includes('FITTING')) {
|
||||
type = 'FITTING';
|
||||
} else if (descUpper.includes('PIPE')) {
|
||||
type = 'PIPE';
|
||||
}
|
||||
|
||||
// 도면 정보 (drawing_name 또는 line_no에서 추출)
|
||||
const drawing = material.drawing_name || material.line_no || '-';
|
||||
|
||||
// 설명을 항목별로 분리 (콤마, 세미콜론, 파이프 등으로 구분)
|
||||
const parts = description
|
||||
.split(/[,;|\/]/)
|
||||
.map(part => part.trim())
|
||||
.filter(part => part.length > 0);
|
||||
|
||||
// 최대 4개 항목으로 제한
|
||||
const detail1 = parts[0] || '-';
|
||||
const detail2 = parts[1] || '-';
|
||||
const detail3 = parts[2] || '-';
|
||||
const detail4 = parts[3] || '-';
|
||||
|
||||
return {
|
||||
type,
|
||||
drawing,
|
||||
detail1,
|
||||
detail2,
|
||||
detail3,
|
||||
detail4,
|
||||
quantity: qty,
|
||||
originalQuantity: qty,
|
||||
purchaseQuantity: qty,
|
||||
isSpecial: true
|
||||
};
|
||||
};
|
||||
|
||||
// 정렬 처리
|
||||
const handleSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
// 필터링 및 정렬된 자료
|
||||
const getFilteredAndSortedMaterials = () => {
|
||||
let filtered = materials.filter(material => {
|
||||
const info = parseSpecialInfo(material);
|
||||
|
||||
// 컬럼 필터 적용
|
||||
for (const [key, filterValue] of Object.entries(columnFilters)) {
|
||||
if (filterValue && filterValue.trim()) {
|
||||
const materialValue = String(info[key] || '').toLowerCase();
|
||||
const filter = filterValue.toLowerCase();
|
||||
if (!materialValue.includes(filter)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 정렬 적용
|
||||
if (sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = parseSpecialInfo(a);
|
||||
const bInfo = parseSpecialInfo(b);
|
||||
|
||||
const aValue = aInfo[sortConfig.key];
|
||||
const bValue = bInfo[sortConfig.key];
|
||||
|
||||
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleSelectAll = () => {
|
||||
const selectableMaterials = filteredMaterials.filter(material =>
|
||||
!purchasedMaterials.has(material.id)
|
||||
);
|
||||
|
||||
const allSelected = selectableMaterials.every(material =>
|
||||
selectedMaterials.has(material.id)
|
||||
);
|
||||
|
||||
if (allSelected) {
|
||||
// 전체 해제
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
selectableMaterials.forEach(material => {
|
||||
newSelected.delete(material.id);
|
||||
});
|
||||
setSelectedMaterials(newSelected);
|
||||
} else {
|
||||
// 전체 선택
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
selectableMaterials.forEach(material => {
|
||||
newSelected.add(material.id);
|
||||
});
|
||||
setSelectedMaterials(newSelected);
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택/해제
|
||||
const handleMaterialSelect = (materialId) => {
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (newSelected.has(materialId)) {
|
||||
newSelected.delete(materialId);
|
||||
} else {
|
||||
newSelected.add(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExportToExcel = async () => {
|
||||
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||
if (selectedMaterialsData.length === 0) {
|
||||
alert('내보낼 자재를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const excelFileName = `SPECIAL_Materials_${timestamp}.xlsx`;
|
||||
|
||||
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||
...material,
|
||||
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 스페셜 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'SPECIAL',
|
||||
filename: excelFileName,
|
||||
user: user?.username || 'unknown'
|
||||
});
|
||||
|
||||
// 2. 구매신청 생성
|
||||
console.log('📝 구매신청 생성 중...');
|
||||
const purchaseResponse = await api.post('/purchase-request/create', {
|
||||
materials_data: dataWithRequirements,
|
||||
file_id: fileId,
|
||||
job_no: jobNo
|
||||
});
|
||||
|
||||
console.log('✅ 구매신청 생성 완료:', purchaseResponse.data);
|
||||
|
||||
// 3. 엑셀 파일을 서버에 업로드
|
||||
console.log('📤 엑셀 파일 서버 업로드 중...');
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', purchaseResponse.data.request_id);
|
||||
formData.append('filename', excelFileName);
|
||||
|
||||
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||
|
||||
// 4. 구매신청된 자재들을 비활성화
|
||||
const purchasedIds = selectedMaterialsData.map(m => m.id);
|
||||
onPurchasedMaterialsUpdate(purchasedIds);
|
||||
|
||||
// 5. 선택 해제
|
||||
setSelectedMaterials(new Set());
|
||||
|
||||
// 6. 클라이언트에서도 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
console.log('✅ 스페셜 엑셀 내보내기 완료');
|
||||
alert(`스페셜 자재 엑셀 파일이 생성되었습니다.\n파일명: ${excelFileName}`);
|
||||
|
||||
// 구매신청 관리 페이지로 이동
|
||||
if (onNavigate) {
|
||||
onNavigate('purchase-requests');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 스페셜 엑셀 내보내기 실패:', error);
|
||||
alert('엑셀 내보내기 중 오류가 발생했습니다: ' + (error.response?.data?.detail || error.message));
|
||||
}
|
||||
};
|
||||
|
||||
const allSelected = filteredMaterials.length > 0 &&
|
||||
filteredMaterials.filter(material => !purchasedMaterials.has(material.id))
|
||||
.every(material => selectedMaterials.has(material.id));
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px',
|
||||
padding: '20px',
|
||||
background: 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)',
|
||||
borderRadius: '12px',
|
||||
color: 'white'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: '24px', fontWeight: '700' }}>
|
||||
Special Items
|
||||
</h2>
|
||||
<p style={{ margin: '8px 0 0 0', opacity: 0.9 }}>
|
||||
특수 제작 품목 관리 ({filteredMaterials.length}개)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: selectedMaterials.size > 0 ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.1)',
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 0.2s ease',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}
|
||||
>
|
||||
구매신청 ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '600px',
|
||||
border: '1px solid #e5e7eb',
|
||||
minWidth: '1400px'
|
||||
}}>
|
||||
<div style={{ minWidth: '1400px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 150px 200px 200px 200px 200px 200px 250px 150px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '2px solid #e2e8f0',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
color: '#1f2937',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={handleSelectAll}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader
|
||||
sortKey="type"
|
||||
filterKey="type"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Type
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="drawing"
|
||||
filterKey="drawing"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Drawing
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="detail1"
|
||||
filterKey="detail1"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Detail 1
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="detail2"
|
||||
filterKey="detail2"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Detail 2
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="detail3"
|
||||
filterKey="detail3"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Detail 3
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="detail4"
|
||||
filterKey="detail4"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Detail 4
|
||||
</FilterableHeader>
|
||||
<div>Additional Request</div>
|
||||
<div>Purchase Quantity</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
<div>
|
||||
{filteredMaterials.map((material, index) => {
|
||||
const info = parseSpecialInfo(material);
|
||||
const isSelected = selectedMaterials.has(material.id);
|
||||
const isPurchased = purchasedMaterials.has(material.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 150px 200px 200px 200px 200px 200px 250px 150px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleMaterialSelect(material.id)}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||
{info.type}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.drawing}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail1}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail2}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail3}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail4}</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||
disabled={isPurchased || savingRequest[material.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.purchaseQuantity}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#6b7280',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
marginTop: '20px'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📋</div>
|
||||
<h3 style={{ margin: '0 0 8px 0', color: '#374151' }}>스페셜 자재가 없습니다</h3>
|
||||
<p style={{ margin: 0 }}>특수 제작이 필요한 자재가 발견되면 여기에 표시됩니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpecialMaterialsView;
|
||||
687
tkeg/web/src/components/bom/materials/SupportMaterialsView.jsx
Normal file
687
tkeg/web/src/components/bom/materials/SupportMaterialsView.jsx
Normal file
@@ -0,0 +1,687 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader } from '../shared';
|
||||
|
||||
const SupportMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
jobNo,
|
||||
fileId,
|
||||
user
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedRequestsData = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
}
|
||||
});
|
||||
|
||||
setSavedRequests(savedRequestsData);
|
||||
};
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
const parseSupportInfo = (material) => {
|
||||
const desc = material.original_description || '';
|
||||
const descUpper = desc.toUpperCase();
|
||||
|
||||
// 서포트 타입 분류 (백엔드 분류기와 동일한 로직)
|
||||
let supportType = 'U-BOLT'; // 기본값
|
||||
|
||||
if (descUpper.includes('URETHANE') || descUpper.includes('BLOCK SHOE') || descUpper.includes('우레탄')) {
|
||||
supportType = 'URETHANE BLOCK SHOE';
|
||||
} else if (descUpper.includes('CLAMP') || descUpper.includes('클램프')) {
|
||||
// 클램프 타입 상세 분류 (CL-1, CL-2, CL-3 등)
|
||||
const clampMatch = desc.match(/CL[-\s]*(\d+)/i);
|
||||
if (clampMatch) {
|
||||
supportType = `CLAMP CL-${clampMatch[1]}`;
|
||||
} else {
|
||||
supportType = 'CLAMP CL-1'; // 기본값
|
||||
}
|
||||
} else if (descUpper.includes('HANGER') || descUpper.includes('행거')) {
|
||||
supportType = 'HANGER';
|
||||
} else if (descUpper.includes('SPRING') || descUpper.includes('스프링')) {
|
||||
supportType = 'SPRING HANGER';
|
||||
} else if (descUpper.includes('GUIDE') || descUpper.includes('가이드')) {
|
||||
supportType = 'GUIDE';
|
||||
} else if (descUpper.includes('ANCHOR') || descUpper.includes('앵커')) {
|
||||
supportType = 'ANCHOR';
|
||||
}
|
||||
|
||||
// User Requirements 추출 (분류기에서 제공된 것 우선)
|
||||
const userRequirements = material.user_requirements || [];
|
||||
|
||||
// 구매 수량 계산 (서포트는 취합된 숫자 그대로)
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
const purchaseQty = qty;
|
||||
|
||||
// Material Grade 처리 - 우레탄 블럭슈의 경우 두께 정보 포함
|
||||
let materialGrade = material.full_material_grade || material.material_grade || '-';
|
||||
|
||||
if (supportType === 'URETHANE BLOCK SHOE') {
|
||||
// 두께 정보 추출 (40t, 27t 등 - 여기서 t는 thickness를 의미)
|
||||
const thicknessMatch = desc.match(/(\d+)\s*[tT]/);
|
||||
if (thicknessMatch) {
|
||||
const thickness = `${thicknessMatch[1]}t`;
|
||||
if (materialGrade === '-' || !materialGrade) {
|
||||
materialGrade = thickness;
|
||||
} else if (!materialGrade.includes(thickness)) {
|
||||
materialGrade = `${materialGrade} ${thickness}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: supportType,
|
||||
size: material.main_nom || material.size_inch || material.size_spec || '-',
|
||||
grade: materialGrade,
|
||||
userRequirements: userRequirements.join(', ') || '-',
|
||||
additionalReq: '-',
|
||||
purchaseQuantity: `${purchaseQty} EA`,
|
||||
originalQuantity: qty,
|
||||
isSupport: true
|
||||
};
|
||||
};
|
||||
|
||||
// 동일한 서포트 항목 합산
|
||||
const consolidateSupportMaterials = (materials) => {
|
||||
const consolidated = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
const info = parseSupportInfo(material);
|
||||
const key = `${info.type}|${info.size}|${info.grade}`;
|
||||
|
||||
if (!consolidated[key]) {
|
||||
consolidated[key] = {
|
||||
...material,
|
||||
// Material Grade 정보를 parsedInfo에서 가져와서 설정
|
||||
material_grade: info.grade,
|
||||
full_material_grade: info.grade,
|
||||
consolidatedQuantity: info.originalQuantity,
|
||||
consolidatedIds: [material.id],
|
||||
parsedInfo: info
|
||||
};
|
||||
} else {
|
||||
consolidated[key].consolidatedQuantity += info.originalQuantity;
|
||||
consolidated[key].consolidatedIds.push(material.id);
|
||||
}
|
||||
});
|
||||
|
||||
// 합산된 수량으로 구매 수량 재계산 (서포트는 취합된 숫자 그대로)
|
||||
return Object.values(consolidated).map(item => {
|
||||
const purchaseQty = item.consolidatedQuantity;
|
||||
|
||||
return {
|
||||
...item,
|
||||
parsedInfo: {
|
||||
...item.parsedInfo,
|
||||
originalQuantity: item.consolidatedQuantity,
|
||||
purchaseQuantity: `${purchaseQty} EA`
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 정렬 처리
|
||||
const handleSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
// 필터링된 및 정렬된 자재 목록
|
||||
const getFilteredAndSortedMaterials = () => {
|
||||
// 먼저 합산 처리
|
||||
let consolidated = consolidateSupportMaterials(materials);
|
||||
|
||||
// 필터링
|
||||
let filtered = consolidated.filter(material => {
|
||||
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||
if (!filterValue) return true;
|
||||
const info = material.parsedInfo;
|
||||
const value = info[key]?.toString().toLowerCase() || '';
|
||||
return value.includes(filterValue.toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
// 정렬
|
||||
if (sortConfig && sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = a.parsedInfo;
|
||||
const bInfo = b.parsedInfo;
|
||||
|
||||
if (!aInfo || !bInfo) return 0;
|
||||
|
||||
const aValue = aInfo[sortConfig.key];
|
||||
const bValue = bInfo[sortConfig.key];
|
||||
|
||||
// 값이 없는 경우 처리
|
||||
if (aValue === undefined && bValue === undefined) return 0;
|
||||
if (aValue === undefined) return 1;
|
||||
if (bValue === undefined) return -1;
|
||||
|
||||
// 숫자인 경우 숫자로 비교
|
||||
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
|
||||
}
|
||||
|
||||
// 문자열로 비교
|
||||
const aStr = String(aValue).toLowerCase();
|
||||
const bStr = String(bValue).toLowerCase();
|
||||
|
||||
if (sortConfig.direction === 'asc') {
|
||||
return aStr.localeCompare(bStr);
|
||||
} else {
|
||||
return bStr.localeCompare(aStr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// 전체 선택/해제 (구매신청된 자재 제외)
|
||||
const handleSelectAll = () => {
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
const selectableMaterials = filteredMaterials.filter(material =>
|
||||
!material.consolidatedIds.some(id => purchasedMaterials.has(id))
|
||||
);
|
||||
|
||||
if (selectedMaterials.size === selectableMaterials.flatMap(m => m.consolidatedIds).length) {
|
||||
setSelectedMaterials(new Set());
|
||||
} else {
|
||||
const allIds = selectableMaterials.flatMap(m => m.consolidatedIds);
|
||||
setSelectedMaterials(new Set(allIds));
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택 (구매신청된 자재는 선택 불가)
|
||||
const handleMaterialSelect = (consolidatedMaterial) => {
|
||||
const hasAnyPurchased = consolidatedMaterial.consolidatedIds.some(id => purchasedMaterials.has(id));
|
||||
if (hasAnyPurchased) {
|
||||
return; // 구매신청된 자재가 포함된 경우 선택 불가
|
||||
}
|
||||
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
const allSelected = consolidatedMaterial.consolidatedIds.every(id => newSelected.has(id));
|
||||
|
||||
if (allSelected) {
|
||||
// 모두 선택된 경우 모두 해제
|
||||
consolidatedMaterial.consolidatedIds.forEach(id => newSelected.delete(id));
|
||||
} else {
|
||||
// 일부 또는 전체 미선택인 경우 모두 선택
|
||||
consolidatedMaterial.consolidatedIds.forEach(id => newSelected.add(id));
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExportToExcel = async () => {
|
||||
// 선택된 합산 자료 가져오기
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
const selectedConsolidatedMaterials = filteredMaterials.filter(consolidatedMaterial =>
|
||||
consolidatedMaterial.consolidatedIds.some(id => selectedMaterials.has(id))
|
||||
);
|
||||
|
||||
if (selectedConsolidatedMaterials.length === 0) {
|
||||
alert('내보낼 자재를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const excelFileName = `SUPPORT_Materials_${timestamp}.xlsx`;
|
||||
|
||||
// 합산된 자료를 엑셀 형태로 변환
|
||||
const dataWithRequirements = selectedConsolidatedMaterials.map(consolidatedMaterial => ({
|
||||
...consolidatedMaterial,
|
||||
// 합산된 수량으로 덮어쓰기
|
||||
quantity: consolidatedMaterial.consolidatedQuantity,
|
||||
// 사용자 요구사항은 대표 ID 기준 (저장된 데이터 우선)
|
||||
user_requirement: savedRequests[consolidatedMaterial.id] || userRequirements[consolidatedMaterial.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 서포트 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'SUPPORT',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||
|
||||
// 2. 구매신청 생성
|
||||
const allMaterialIds = selectedConsolidatedMaterials.flatMap(cm => cm.consolidatedIds);
|
||||
const response = await api.post('/purchase-request/create', {
|
||||
file_id: fileId,
|
||||
job_no: jobNo,
|
||||
category: 'SUPPORT',
|
||||
material_ids: allMaterialIds,
|
||||
materials_data: dataWithRequirements.map(m => ({
|
||||
material_id: m.id,
|
||||
description: m.original_description,
|
||||
category: m.classified_category,
|
||||
size: m.size_inch || m.size_spec,
|
||||
schedule: m.schedule,
|
||||
material_grade: m.material_grade || m.full_material_grade,
|
||||
quantity: m.quantity, // 이미 합산된 수량
|
||||
unit: m.unit,
|
||||
user_requirement: userRequirements[m.id] || ''
|
||||
}))
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||
|
||||
// 3. 엑셀 파일을 서버에 업로드
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', response.data.request_id);
|
||||
formData.append('category', 'SUPPORT');
|
||||
|
||||
console.log('📤 엑셀 파일 서버 업로드 중...');
|
||||
await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
console.log('✅ 엑셀 파일 서버 업로드 완료');
|
||||
|
||||
// 4. 구매된 자재 목록 업데이트 (비활성화)
|
||||
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||
console.log('✅ 구매된 자재 목록 업데이트 완료');
|
||||
|
||||
// 5. 클라이언트에 파일 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||
} else {
|
||||
throw new Error(response.data?.message || '구매신청 생성 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||
// 실패 시에도 클라이언트 다운로드는 진행
|
||||
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||
category: 'SUPPORT',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||
}
|
||||
|
||||
// 선택 해제
|
||||
setSelectedMaterials(new Set());
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
return (
|
||||
<div style={{ padding: '32px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
Support Materials
|
||||
</h3>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#64748b',
|
||||
margin: 0
|
||||
}}>
|
||||
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)' : '#e5e7eb',
|
||||
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Export to Excel ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
maxHeight: '600px'
|
||||
}}>
|
||||
<div style={{ minWidth: '1200px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 150px 180px 200px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(() => {
|
||||
const selectableMaterials = filteredMaterials.filter(material =>
|
||||
!material.consolidatedIds.some(id => purchasedMaterials.has(id))
|
||||
);
|
||||
return selectedMaterials.size === selectableMaterials.flatMap(m => m.consolidatedIds).length && selectableMaterials.length > 0;
|
||||
})()}
|
||||
onChange={handleSelectAll}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader
|
||||
sortKey="type"
|
||||
filterKey="type"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Type
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="size"
|
||||
filterKey="size"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Size
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="grade"
|
||||
filterKey="grade"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Material Grade
|
||||
</FilterableHeader>
|
||||
<div>User Requirements</div>
|
||||
<div>Additional Request</div>
|
||||
<FilterableHeader
|
||||
sortKey="purchaseQuantity"
|
||||
filterKey="purchaseQuantity"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Purchase Quantity
|
||||
</FilterableHeader>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
{filteredMaterials.map((consolidatedMaterial, index) => {
|
||||
const info = consolidatedMaterial.parsedInfo;
|
||||
const hasAnyPurchased = consolidatedMaterial.consolidatedIds.some(id => purchasedMaterials.has(id));
|
||||
const allSelected = consolidatedMaterial.consolidatedIds.every(id => selectedMaterials.has(id));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`consolidated-${index}`}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 150px 180px 200px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: allSelected ? '#eff6ff' : (hasAnyPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!allSelected && !hasAnyPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!allSelected && !hasAnyPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={() => handleMaterialSelect(consolidatedMaterial)}
|
||||
disabled={hasAnyPurchased}
|
||||
style={{
|
||||
cursor: hasAnyPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: hasAnyPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||
{info.type}
|
||||
{hasAnyPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.size}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.grade}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.userRequirements}</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[consolidatedMaterial.id] && savedRequests[consolidatedMaterial.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[consolidatedMaterial.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(consolidatedMaterial.id, savedRequests[consolidatedMaterial.id])}
|
||||
disabled={hasAnyPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: hasAnyPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: hasAnyPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: hasAnyPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[consolidatedMaterial.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[consolidatedMaterial.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={hasAnyPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
opacity: hasAnyPurchased ? 0.5 : 1,
|
||||
cursor: hasAnyPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(consolidatedMaterial.id, userRequirements[consolidatedMaterial.id] || '')}
|
||||
disabled={hasAnyPurchased || savingRequest[consolidatedMaterial.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: hasAnyPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: hasAnyPurchased || savingRequest[consolidatedMaterial.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: hasAnyPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[consolidatedMaterial.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.purchaseQuantity}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#64748b'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
No Support Materials Found
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||
? 'Try adjusting your filters'
|
||||
: 'No support materials available in this BOM'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupportMaterialsView;
|
||||
@@ -0,0 +1,561 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader } from '../shared';
|
||||
|
||||
const UnclassifiedMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
jobNo,
|
||||
fileId,
|
||||
user,
|
||||
onNavigate
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedRequestsData = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
}
|
||||
});
|
||||
|
||||
setSavedRequests(savedRequestsData);
|
||||
};
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
// 미분류 자재 정보 파싱 (원본 그대로 표시)
|
||||
const parseUnclassifiedInfo = (material) => {
|
||||
const description = material.original_description || material.description || '';
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
|
||||
return {
|
||||
description: description || '-',
|
||||
size: material.main_nom || material.size_spec || '-',
|
||||
drawing: material.drawing_name || material.line_no || '-',
|
||||
lineNo: material.line_no || '-',
|
||||
quantity: qty,
|
||||
originalQuantity: qty,
|
||||
purchaseQuantity: qty,
|
||||
isUnclassified: true
|
||||
};
|
||||
};
|
||||
|
||||
// 정렬 처리
|
||||
const handleSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
// 필터링 및 정렬된 자료
|
||||
const getFilteredAndSortedMaterials = () => {
|
||||
let filtered = materials.filter(material => {
|
||||
const info = parseUnclassifiedInfo(material);
|
||||
|
||||
// 컬럼 필터 적용
|
||||
for (const [key, filterValue] of Object.entries(columnFilters)) {
|
||||
if (filterValue && filterValue.trim()) {
|
||||
const materialValue = String(info[key] || '').toLowerCase();
|
||||
const filter = filterValue.toLowerCase();
|
||||
if (!materialValue.includes(filter)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 정렬 적용
|
||||
if (sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = parseUnclassifiedInfo(a);
|
||||
const bInfo = parseUnclassifiedInfo(b);
|
||||
|
||||
const aValue = aInfo[sortConfig.key];
|
||||
const bValue = bInfo[sortConfig.key];
|
||||
|
||||
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleSelectAll = () => {
|
||||
const selectableMaterials = filteredMaterials.filter(material =>
|
||||
!purchasedMaterials.has(material.id)
|
||||
);
|
||||
|
||||
const allSelected = selectableMaterials.every(material =>
|
||||
selectedMaterials.has(material.id)
|
||||
);
|
||||
|
||||
if (allSelected) {
|
||||
// 전체 해제
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
selectableMaterials.forEach(material => {
|
||||
newSelected.delete(material.id);
|
||||
});
|
||||
setSelectedMaterials(newSelected);
|
||||
} else {
|
||||
// 전체 선택
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
selectableMaterials.forEach(material => {
|
||||
newSelected.add(material.id);
|
||||
});
|
||||
setSelectedMaterials(newSelected);
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택/해제
|
||||
const handleMaterialSelect = (materialId) => {
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (newSelected.has(materialId)) {
|
||||
newSelected.delete(materialId);
|
||||
} else {
|
||||
newSelected.add(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExportToExcel = async () => {
|
||||
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||
if (selectedMaterialsData.length === 0) {
|
||||
alert('내보낼 자재를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const excelFileName = `UNCLASSIFIED_Materials_${timestamp}.xlsx`;
|
||||
|
||||
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||
...material,
|
||||
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 미분류 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'UNCLASSIFIED',
|
||||
filename: excelFileName,
|
||||
user: user?.username || 'unknown'
|
||||
});
|
||||
|
||||
// 2. 구매신청 생성
|
||||
console.log('📝 구매신청 생성 중...');
|
||||
const purchaseResponse = await api.post('/purchase-request/create', {
|
||||
materials_data: dataWithRequirements,
|
||||
file_id: fileId,
|
||||
job_no: jobNo
|
||||
});
|
||||
|
||||
console.log('✅ 구매신청 생성 완료:', purchaseResponse.data);
|
||||
|
||||
// 3. 엑셀 파일을 서버에 업로드
|
||||
console.log('📤 엑셀 파일 서버 업로드 중...');
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', purchaseResponse.data.request_id);
|
||||
formData.append('filename', excelFileName);
|
||||
|
||||
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||
|
||||
// 4. 구매신청된 자재들을 비활성화
|
||||
const purchasedIds = selectedMaterialsData.map(m => m.id);
|
||||
onPurchasedMaterialsUpdate(purchasedIds);
|
||||
|
||||
// 5. 선택 해제
|
||||
setSelectedMaterials(new Set());
|
||||
|
||||
// 6. 클라이언트에서도 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
console.log('✅ 미분류 엑셀 내보내기 완료');
|
||||
alert(`미분류 자재 엑셀 파일이 생성되었습니다.\n파일명: ${excelFileName}`);
|
||||
|
||||
// 구매신청 관리 페이지로 이동
|
||||
if (onNavigate) {
|
||||
onNavigate('purchase-requests');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 미분류 엑셀 내보내기 실패:', error);
|
||||
alert('엑셀 내보내기 중 오류가 발생했습니다: ' + (error.response?.data?.detail || error.message));
|
||||
}
|
||||
};
|
||||
|
||||
const allSelected = filteredMaterials.length > 0 &&
|
||||
filteredMaterials.filter(material => !purchasedMaterials.has(material.id))
|
||||
.every(material => selectedMaterials.has(material.id));
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px',
|
||||
padding: '20px',
|
||||
background: 'linear-gradient(135deg, #64748b 0%, #475569 100%)',
|
||||
borderRadius: '12px',
|
||||
color: 'white'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: '24px', fontWeight: '700' }}>
|
||||
Unclassified Materials
|
||||
</h2>
|
||||
<p style={{ margin: '8px 0 0 0', opacity: 0.9 }}>
|
||||
분류되지 않은 자재 관리 ({filteredMaterials.length}개)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: selectedMaterials.size > 0 ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.1)',
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 0.2s ease',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}
|
||||
>
|
||||
구매신청 ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '600px',
|
||||
border: '1px solid #e5e7eb',
|
||||
minWidth: '1200px'
|
||||
}}>
|
||||
<div style={{ minWidth: '1200px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 400px 150px 200px 150px 250px 150px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '2px solid #e2e8f0',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
color: '#1f2937',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={handleSelectAll}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader
|
||||
sortKey="description"
|
||||
filterKey="description"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Description
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="size"
|
||||
filterKey="size"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Size
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="drawing"
|
||||
filterKey="drawing"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Drawing
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="lineNo"
|
||||
filterKey="lineNo"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Line No
|
||||
</FilterableHeader>
|
||||
<div>Additional Request</div>
|
||||
<div>Purchase Quantity</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
<div>
|
||||
{filteredMaterials.map((material, index) => {
|
||||
const info = parseUnclassifiedInfo(material);
|
||||
const isSelected = selectedMaterials.has(material.id);
|
||||
const isPurchased = purchasedMaterials.has(material.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 400px 150px 200px 150px 250px 150px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleMaterialSelect(material.id)}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#1f2937',
|
||||
textAlign: 'left',
|
||||
paddingLeft: '8px',
|
||||
wordBreak: 'break-word'
|
||||
}}>
|
||||
{info.description}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.size}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.drawing}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.lineNo}</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||
disabled={isPurchased || savingRequest[material.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.purchaseQuantity}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#6b7280',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
marginTop: '20px'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>❓</div>
|
||||
<h3 style={{ margin: '0 0 8px 0', color: '#374151' }}>미분류 자재가 없습니다</h3>
|
||||
<p style={{ margin: 0 }}>분류되지 않은 자재가 발견되면 여기에 표시됩니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnclassifiedMaterialsView;
|
||||
840
tkeg/web/src/components/bom/materials/ValveMaterialsView.jsx
Normal file
840
tkeg/web/src/components/bom/materials/ValveMaterialsView.jsx
Normal file
@@ -0,0 +1,840 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||
import api from '../../../api';
|
||||
import { FilterableHeader } from '../shared';
|
||||
|
||||
const ValveMaterialsView = ({
|
||||
materials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate,
|
||||
updateMaterial, // 자재 업데이트 함수
|
||||
fileId,
|
||||
jobNo,
|
||||
user
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||
const [brandInputs, setBrandInputs] = useState({}); // 브랜드 입력 상태
|
||||
const [savingBrand, setSavingBrand] = useState({}); // 브랜드 저장 중 상태
|
||||
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||
const [savedBrands, setSavedBrands] = useState({}); // 저장된 브랜드 상태
|
||||
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||
const [editingBrand, setEditingBrand] = useState({}); // 브랜드 편집 모드
|
||||
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const loadSavedData = () => {
|
||||
const savedBrandsData = {};
|
||||
const savedRequestsData = {};
|
||||
|
||||
console.log('🔍 ValveMaterialsView useEffect 트리거됨:', materials.length, '개 자재');
|
||||
console.log('🔍 현재 materials 배열:', materials.map(m => ({id: m.id, brand: m.brand, user_requirement: m.user_requirement})));
|
||||
|
||||
materials.forEach(material => {
|
||||
// 백엔드에서 가져온 데이터가 있으면 저장된 상태로 설정
|
||||
if (material.brand && material.brand.trim()) {
|
||||
savedBrandsData[material.id] = material.brand.trim();
|
||||
console.log('✅ 브랜드 로드됨:', material.id, '→', material.brand);
|
||||
}
|
||||
if (material.user_requirement && material.user_requirement.trim()) {
|
||||
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||
console.log('✅ 요구사항 로드됨:', material.id, '→', material.user_requirement);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('💾 최종 저장된 브랜드:', savedBrandsData);
|
||||
console.log('💾 최종 저장된 요구사항:', savedRequestsData);
|
||||
|
||||
// 상태 업데이트를 즉시 반영하기 위해 setTimeout 사용
|
||||
setSavedBrands(savedBrandsData);
|
||||
setSavedRequests(savedRequestsData);
|
||||
|
||||
// 상태 업데이트 후 강제 리렌더링 확인
|
||||
setTimeout(() => {
|
||||
console.log('🔄 상태 업데이트 후 확인 - savedBrands:', savedBrandsData);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
console.log('🔄 ValveMaterialsView useEffect 실행 - materials 길이:', materials?.length || 0);
|
||||
|
||||
if (materials && materials.length > 0) {
|
||||
loadSavedData();
|
||||
} else {
|
||||
console.log('⚠️ materials가 비어있거나 undefined');
|
||||
// 빈 상태로 초기화
|
||||
setSavedBrands({});
|
||||
setSavedRequests({});
|
||||
}
|
||||
}, [materials]);
|
||||
|
||||
const parseValveInfo = (material) => {
|
||||
const valveDetails = material.valve_details || {};
|
||||
const description = material.original_description || '';
|
||||
const descUpper = description.toUpperCase();
|
||||
|
||||
// 1. 벨브 타입 파싱 (한글명으로 표시)
|
||||
let valveType = '';
|
||||
if (descUpper.includes('SIGHT GLASS') || descUpper.includes('사이트글라스')) {
|
||||
valveType = 'SIGHT GLASS';
|
||||
} else if (descUpper.includes('STRAINER') || descUpper.includes('스트레이너')) {
|
||||
valveType = 'STRAINER';
|
||||
} else if (descUpper.includes('GATE') || descUpper.includes('게이트')) {
|
||||
valveType = 'GATE VALVE';
|
||||
} else if (descUpper.includes('BALL') || descUpper.includes('볼')) {
|
||||
valveType = 'BALL VALVE';
|
||||
} else if (descUpper.includes('CHECK') || descUpper.includes('체크')) {
|
||||
valveType = 'CHECK VALVE';
|
||||
} else if (descUpper.includes('GLOBE') || descUpper.includes('글로브')) {
|
||||
valveType = 'GLOBE VALVE';
|
||||
} else if (descUpper.includes('BUTTERFLY') || descUpper.includes('버터플라이')) {
|
||||
valveType = 'BUTTERFLY VALVE';
|
||||
} else if (descUpper.includes('NEEDLE') || descUpper.includes('니들')) {
|
||||
valveType = 'NEEDLE VALVE';
|
||||
} else if (descUpper.includes('RELIEF') || descUpper.includes('릴리프')) {
|
||||
valveType = 'RELIEF VALVE';
|
||||
} else {
|
||||
valveType = 'VALVE';
|
||||
}
|
||||
|
||||
// 2. 사이즈 정보
|
||||
const size = material.main_nom || material.size_inch || material.size_spec || '-';
|
||||
|
||||
// 3. 압력 등급
|
||||
const pressure = material.pressure_rating ||
|
||||
(descUpper.match(/(\d+)\s*LB/) ? descUpper.match(/(\d+)\s*LB/)[0] : '-');
|
||||
|
||||
// 4. 브랜드 (사용자 입력 가능)
|
||||
const brand = '-'; // 기본값, 사용자가 입력할 수 있도록
|
||||
|
||||
// 5. 추가 정보 추출 (3-WAY, DOUL PLATE, DOUBLE DISC 등)
|
||||
let additionalInfo = '';
|
||||
const additionalPatterns = [
|
||||
'3-WAY', '3WAY', 'THREE WAY',
|
||||
'DOUL PLATE', 'DOUBLE PLATE', 'DUAL PLATE',
|
||||
'DOUBLE DISC', 'DUAL DISC',
|
||||
'SWING', 'LIFT', 'TILTING',
|
||||
'WAFER', 'LUG', 'FLANGED',
|
||||
'FULL BORE', 'REDUCED BORE',
|
||||
'FIRE SAFE', 'ANTI STATIC'
|
||||
];
|
||||
|
||||
for (const pattern of additionalPatterns) {
|
||||
if (descUpper.includes(pattern)) {
|
||||
if (additionalInfo) {
|
||||
additionalInfo += ', ';
|
||||
}
|
||||
additionalInfo += pattern;
|
||||
}
|
||||
}
|
||||
|
||||
if (!additionalInfo) {
|
||||
additionalInfo = '-';
|
||||
}
|
||||
|
||||
// 6. 연결 방식 (투입구/Connection Type)
|
||||
let connectionType = '';
|
||||
if (descUpper.includes('SW X THRD') || descUpper.includes('SW×THRD')) {
|
||||
connectionType = 'SW×THRD';
|
||||
} else if (descUpper.includes('FLG') || descUpper.includes('FLANGE')) {
|
||||
connectionType = 'FLG';
|
||||
} else if (descUpper.includes('SW') || descUpper.includes('SOCKET')) {
|
||||
connectionType = 'SW';
|
||||
} else if (descUpper.includes('THRD') || descUpper.includes('THREAD')) {
|
||||
connectionType = 'THRD';
|
||||
} else if (descUpper.includes('BW') || descUpper.includes('BUTT WELD')) {
|
||||
connectionType = 'BW';
|
||||
} else {
|
||||
connectionType = '-';
|
||||
}
|
||||
|
||||
// 7. 구매 수량 계산 (기본 수량 그대로)
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
const purchaseQuantity = `${qty} EA`;
|
||||
|
||||
return {
|
||||
type: valveType, // 벨브 종류만 (GATE VALVE, BALL VALVE 등)
|
||||
size: size,
|
||||
pressure: pressure,
|
||||
brand: brand, // 브랜드 (사용자 입력 가능)
|
||||
additionalInfo: additionalInfo, // 추가 정보 (3-WAY, DOUL PLATE 등)
|
||||
connection: connectionType, // 투입구/연결방식 (SW, FLG 등)
|
||||
additionalRequest: '-', // 추가 요구사항 (기존 User Requirement)
|
||||
purchaseQuantity: purchaseQuantity,
|
||||
originalQuantity: qty,
|
||||
isValve: true
|
||||
};
|
||||
};
|
||||
|
||||
// 정렬 처리
|
||||
const handleSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
// 필터링된 및 정렬된 자재 목록
|
||||
const getFilteredAndSortedMaterials = () => {
|
||||
let filtered = materials.filter(material => {
|
||||
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||
if (!filterValue) return true;
|
||||
const info = parseValveInfo(material);
|
||||
const value = info[key]?.toString().toLowerCase() || '';
|
||||
return value.includes(filterValue.toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
if (sortConfig && sortConfig.key) {
|
||||
filtered.sort((a, b) => {
|
||||
const aInfo = parseValveInfo(a);
|
||||
const bInfo = parseValveInfo(b);
|
||||
|
||||
if (!aInfo || !bInfo) return 0;
|
||||
|
||||
const aValue = aInfo[sortConfig.key];
|
||||
const bValue = bInfo[sortConfig.key];
|
||||
|
||||
// 값이 없는 경우 처리
|
||||
if (aValue === undefined && bValue === undefined) return 0;
|
||||
if (aValue === undefined) return 1;
|
||||
if (bValue === undefined) return -1;
|
||||
|
||||
// 숫자인 경우 숫자로 비교
|
||||
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
|
||||
}
|
||||
|
||||
// 문자열로 비교
|
||||
const aStr = String(aValue).toLowerCase();
|
||||
const bStr = String(bValue).toLowerCase();
|
||||
|
||||
if (sortConfig.direction === 'asc') {
|
||||
return aStr.localeCompare(bStr);
|
||||
} else {
|
||||
return bStr.localeCompare(aStr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// 전체 선택/해제 (구매신청된 자재 제외)
|
||||
// 브랜드 저장 함수
|
||||
const handleSaveBrand = async (materialId, brand) => {
|
||||
if (!brand.trim()) return;
|
||||
|
||||
setSavingBrand(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/brand`, { brand: brand.trim() });
|
||||
// 성공 시 저장된 상태로 전환
|
||||
setSavedBrands(prev => ({ ...prev, [materialId]: brand.trim() }));
|
||||
setEditingBrand(prev => ({ ...prev, [materialId]: false }));
|
||||
setBrandInputs(prev => ({ ...prev, [materialId]: '' })); // 입력 필드 초기화
|
||||
|
||||
// materials 배열도 업데이트 (카테고리 변경 시 데이터 유지를 위해)
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { brand: brand.trim() });
|
||||
console.log('✅ materials 배열 업데이트 완료:', materialId, '→', brand.trim());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('브랜드 저장 실패:', error);
|
||||
alert('브랜드 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingBrand(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 브랜드 편집 시작
|
||||
const handleEditBrand = (materialId, currentBrand) => {
|
||||
setEditingBrand(prev => ({ ...prev, [materialId]: true }));
|
||||
setBrandInputs(prev => ({ ...prev, [materialId]: currentBrand || '' }));
|
||||
};
|
||||
|
||||
// 추가요구사항 저장 함수
|
||||
const handleSaveRequest = async (materialId, request) => {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
try {
|
||||
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||
user_requirement: request.trim()
|
||||
});
|
||||
// 성공 시 저장된 상태로 전환
|
||||
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: '' })); // 입력 필드 초기화
|
||||
|
||||
// materials 배열도 업데이트 (카테고리 변경 시 데이터 유지를 위해)
|
||||
if (updateMaterial) {
|
||||
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||
console.log('✅ materials 배열 업데이트 완료:', materialId, '→', request.trim());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추가요구사항 저장 실패:', error);
|
||||
alert('추가요구사항 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 추가요구사항 편집 시작
|
||||
const handleEditRequest = (materialId, currentRequest) => {
|
||||
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
|
||||
if (selectedMaterials.size === selectableMaterials.length) {
|
||||
setSelectedMaterials(new Set());
|
||||
} else {
|
||||
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택 (구매신청된 자재는 선택 불가)
|
||||
const handleMaterialSelect = (materialId) => {
|
||||
if (purchasedMaterials.has(materialId)) {
|
||||
return; // 구매신청된 자재는 선택 불가
|
||||
}
|
||||
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (newSelected.has(materialId)) {
|
||||
newSelected.delete(materialId);
|
||||
} else {
|
||||
newSelected.add(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExportToExcel = async () => {
|
||||
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||
if (selectedMaterialsData.length === 0) {
|
||||
alert('내보낼 자재를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const excelFileName = `VALVE_Materials_${timestamp}.xlsx`;
|
||||
|
||||
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||
...material,
|
||||
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('🔄 밸브 엑셀 내보내기 시작 - 새로운 방식');
|
||||
|
||||
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||
category: 'VALVE',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||
|
||||
// 2. 구매신청 생성
|
||||
const allMaterialIds = selectedMaterialsData.map(m => m.id);
|
||||
const response = await api.post('/purchase-request/create', {
|
||||
file_id: fileId,
|
||||
job_no: jobNo,
|
||||
category: 'VALVE',
|
||||
material_ids: allMaterialIds,
|
||||
materials_data: dataWithRequirements.map(m => ({
|
||||
material_id: m.id,
|
||||
description: m.original_description,
|
||||
category: m.classified_category,
|
||||
size: m.size_inch || m.size_spec,
|
||||
schedule: m.schedule,
|
||||
material_grade: m.material_grade || m.full_material_grade,
|
||||
quantity: m.quantity,
|
||||
unit: m.unit,
|
||||
user_requirement: userRequirements[m.id] || ''
|
||||
}))
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||
|
||||
// 3. 생성된 엑셀 파일을 서버에 업로드
|
||||
console.log('📤 서버에 엑셀 파일 업로드 중...');
|
||||
const formData = new FormData();
|
||||
formData.append('excel_file', excelBlob, excelFileName);
|
||||
formData.append('request_id', response.data.request_id);
|
||||
formData.append('category', 'VALVE');
|
||||
|
||||
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||
|
||||
if (onPurchasedMaterialsUpdate) {
|
||||
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 클라이언트 다운로드
|
||||
const url = window.URL.createObjectURL(excelBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = excelFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||
} catch (error) {
|
||||
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||
// 실패 시에도 클라이언트 다운로드는 진행
|
||||
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||
category: 'VALVE',
|
||||
filename: excelFileName,
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
});
|
||||
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||
|
||||
return (
|
||||
<div style={{ padding: '32px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
Valve Materials
|
||||
</h3>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#64748b',
|
||||
margin: 0
|
||||
}}>
|
||||
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' : '#e5e7eb',
|
||||
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Export to Excel ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
maxHeight: '600px'
|
||||
}}>
|
||||
<div style={{ minWidth: '1600px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 120px 150px 180px 180px 150px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(() => {
|
||||
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
|
||||
})()}
|
||||
onChange={handleSelectAll}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader
|
||||
sortKey="type"
|
||||
filterKey="type"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Type
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="size"
|
||||
filterKey="size"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Size
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="pressure"
|
||||
filterKey="pressure"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Pressure
|
||||
</FilterableHeader>
|
||||
<div>Brand</div>
|
||||
<FilterableHeader
|
||||
sortKey="additionalInfo"
|
||||
filterKey="additionalInfo"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Additional Info
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="connection"
|
||||
filterKey="connection"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Connection
|
||||
</FilterableHeader>
|
||||
<div>Additional Request</div>
|
||||
<FilterableHeader
|
||||
sortKey="purchaseQuantity"
|
||||
filterKey="purchaseQuantity"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Purchase Quantity
|
||||
</FilterableHeader>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
{filteredMaterials.map((material, index) => {
|
||||
const info = parseValveInfo(material);
|
||||
const isSelected = selectedMaterials.has(material.id);
|
||||
const isPurchased = purchasedMaterials.has(material.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 120px 150px 180px 180px 150px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleMaterialSelect(material.id)}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||
{info.type}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.size}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.pressure}</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{(() => {
|
||||
// 디버깅: 렌더링 시점의 상태 확인
|
||||
const hasEditingBrand = !!editingBrand[material.id];
|
||||
const hasSavedBrand = !!savedBrands[material.id];
|
||||
const shouldShowSaved = !hasEditingBrand && hasSavedBrand;
|
||||
|
||||
if (material.id === 11789) { // 테스트 자재만 로그
|
||||
console.log(`🎨 UI 렌더링 - ID ${material.id}:`, {
|
||||
editingBrand: hasEditingBrand,
|
||||
savedBrandExists: hasSavedBrand,
|
||||
savedBrandValue: savedBrands[material.id],
|
||||
shouldShowSaved: shouldShowSaved,
|
||||
allSavedBrands: Object.keys(savedBrands),
|
||||
renderingMode: shouldShowSaved ? 'SAVED_VIEW' : 'INPUT_VIEW'
|
||||
});
|
||||
}
|
||||
|
||||
// 명시적으로 boolean 반환
|
||||
return shouldShowSaved ? true : false;
|
||||
})() ? (
|
||||
// 저장된 상태 - 브랜드 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedBrands[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditBrand(material.id, savedBrands[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={brandInputs[material.id] || ''}
|
||||
onChange={(e) => setBrandInputs({
|
||||
...brandInputs,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter brand..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
textAlign: 'center',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveBrand(material.id, brandInputs[material.id] || '')}
|
||||
disabled={isPurchased || savingBrand[material.id] || !brandInputs[material.id]?.trim()}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#3b82f6',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingBrand[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased || !brandInputs[material.id]?.trim() ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingBrand[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.additionalInfo}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.connection}</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||
disabled={isPurchased || savingRequest[material.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.purchaseQuantity}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#64748b'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
No Valve Materials Found
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||
? 'Try adjusting your filters'
|
||||
: 'No valve materials available in this BOM'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValveMaterialsView;
|
||||
10
tkeg/web/src/components/bom/materials/index.js
Normal file
10
tkeg/web/src/components/bom/materials/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// BOM Materials Components
|
||||
export { default as PipeMaterialsView } from './PipeMaterialsView';
|
||||
export { default as FittingMaterialsView } from './FittingMaterialsView';
|
||||
export { default as FlangeMaterialsView } from './FlangeMaterialsView';
|
||||
export { default as ValveMaterialsView } from './ValveMaterialsView';
|
||||
export { default as GasketMaterialsView } from './GasketMaterialsView';
|
||||
export { default as BoltMaterialsView } from './BoltMaterialsView';
|
||||
export { default as SupportMaterialsView } from './SupportMaterialsView';
|
||||
export { default as SpecialMaterialsView } from './SpecialMaterialsView';
|
||||
export { default as UnclassifiedMaterialsView } from './UnclassifiedMaterialsView';
|
||||
78
tkeg/web/src/components/bom/shared/FilterableHeader.jsx
Normal file
78
tkeg/web/src/components/bom/shared/FilterableHeader.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
|
||||
const FilterableHeader = ({
|
||||
sortKey,
|
||||
filterKey,
|
||||
children,
|
||||
sortConfig,
|
||||
onSort,
|
||||
columnFilters,
|
||||
onFilterChange,
|
||||
showFilterDropdown,
|
||||
setShowFilterDropdown
|
||||
}) => {
|
||||
return (
|
||||
<div className="filterable-header" style={{ position: 'relative' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span
|
||||
onClick={() => onSort(sortKey)}
|
||||
style={{ cursor: 'pointer', flex: 1 }}
|
||||
>
|
||||
{children}
|
||||
{sortConfig && sortConfig.key === sortKey && (
|
||||
<span style={{ marginLeft: '4px' }}>
|
||||
{sortConfig.direction === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
fontSize: '12px',
|
||||
color: '#6b7280'
|
||||
}}
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
</div>
|
||||
{showFilterDropdown === filterKey && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
background: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
padding: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
zIndex: 1000,
|
||||
minWidth: '150px'
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Filter ${children}...`}
|
||||
value={columnFilters[filterKey] || ''}
|
||||
onChange={(e) => onFilterChange({
|
||||
...columnFilters,
|
||||
[filterKey]: e.target.value
|
||||
})}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterableHeader;
|
||||
161
tkeg/web/src/components/bom/shared/MaterialTable.jsx
Normal file
161
tkeg/web/src/components/bom/shared/MaterialTable.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
|
||||
const MaterialTable = ({
|
||||
children,
|
||||
className = '',
|
||||
style = {}
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`material-table ${className}`}
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
...style
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MaterialTableHeader = ({
|
||||
children,
|
||||
gridColumns,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`material-table-header ${className}`}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: gridColumns,
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MaterialTableBody = ({
|
||||
children,
|
||||
maxHeight = '600px',
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`material-table-body ${className}`}
|
||||
style={{
|
||||
maxHeight,
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MaterialTableRow = ({
|
||||
children,
|
||||
gridColumns,
|
||||
isSelected = false,
|
||||
isPurchased = false,
|
||||
isLast = false,
|
||||
onClick,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`material-table-row ${className}`}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: gridColumns,
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: !isLast ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease',
|
||||
cursor: onClick ? 'pointer' : 'default'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isPurchased && !onClick) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isPurchased && !onClick) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MaterialTableCell = ({
|
||||
children,
|
||||
align = 'left',
|
||||
fontWeight = 'normal',
|
||||
color = '#1f2937',
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`material-table-cell ${className}`}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color,
|
||||
fontWeight,
|
||||
textAlign: align
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MaterialTableEmpty = ({
|
||||
icon = '📦',
|
||||
title = 'No Materials Found',
|
||||
message = 'No materials available',
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`material-table-empty ${className}`}
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#64748b'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>{icon}</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 복합 컴포넌트로 export
|
||||
MaterialTable.Header = MaterialTableHeader;
|
||||
MaterialTable.Body = MaterialTableBody;
|
||||
MaterialTable.Row = MaterialTableRow;
|
||||
MaterialTable.Cell = MaterialTableCell;
|
||||
MaterialTable.Empty = MaterialTableEmpty;
|
||||
|
||||
export default MaterialTable;
|
||||
3
tkeg/web/src/components/bom/shared/index.js
Normal file
3
tkeg/web/src/components/bom/shared/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// BOM Shared Components
|
||||
export { default as FilterableHeader } from './FilterableHeader';
|
||||
export { default as MaterialTable } from './MaterialTable';
|
||||
536
tkeg/web/src/components/bom/tabs/BOMFilesTab.jsx
Normal file
536
tkeg/web/src/components/bom/tabs/BOMFilesTab.jsx
Normal file
@@ -0,0 +1,536 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../../../api';
|
||||
|
||||
const BOMFilesTab = ({
|
||||
selectedProject,
|
||||
user,
|
||||
bomFiles,
|
||||
setBomFiles,
|
||||
selectedBOM,
|
||||
onBOMSelect,
|
||||
refreshTrigger
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [revisionDialog, setRevisionDialog] = useState({ open: false, file: null });
|
||||
const [groupedFiles, setGroupedFiles] = useState({});
|
||||
|
||||
// BOM 파일 목록 로드 함수
|
||||
const loadBOMFiles = async () => {
|
||||
if (!selectedProject) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const projectCode = selectedProject.official_project_code || selectedProject.job_no;
|
||||
const encodedProjectCode = encodeURIComponent(projectCode);
|
||||
const response = await api.get(`/files/project/${encodedProjectCode}`);
|
||||
const files = response.data || [];
|
||||
|
||||
setBomFiles(files);
|
||||
|
||||
// BOM 이름별로 그룹화
|
||||
const groups = groupFilesByBOM(files);
|
||||
setGroupedFiles(groups);
|
||||
|
||||
} catch (err) {
|
||||
console.error('BOM 파일 로드 실패:', err);
|
||||
setError('BOM 파일을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// BOM 파일 목록 로드
|
||||
useEffect(() => {
|
||||
loadBOMFiles();
|
||||
}, [selectedProject, refreshTrigger, setBomFiles]);
|
||||
|
||||
// 파일을 BOM 이름별로 그룹화
|
||||
const groupFilesByBOM = (fileList) => {
|
||||
const groups = {};
|
||||
|
||||
fileList.forEach(file => {
|
||||
const bomName = file.bom_name || file.original_filename;
|
||||
if (!groups[bomName]) {
|
||||
groups[bomName] = [];
|
||||
}
|
||||
groups[bomName].push(file);
|
||||
});
|
||||
|
||||
// 각 그룹 내에서 리비전 번호로 정렬
|
||||
Object.keys(groups).forEach(bomName => {
|
||||
groups[bomName].sort((a, b) => {
|
||||
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
|
||||
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
|
||||
return revB - revA; // 최신 리비전이 위로
|
||||
});
|
||||
});
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
// BOM 선택 처리
|
||||
const handleBOMClick = (bomFile) => {
|
||||
if (onBOMSelect) {
|
||||
onBOMSelect(bomFile);
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 삭제
|
||||
const handleDeleteFile = async (fileId, bomName) => {
|
||||
if (!window.confirm(`이 파일을 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.delete(`/files/delete/${fileId}`);
|
||||
|
||||
// 파일 목록 새로고침
|
||||
const projectCode = selectedProject.official_project_code || selectedProject.job_no;
|
||||
const encodedProjectCode = encodeURIComponent(projectCode);
|
||||
const response = await api.get(`/files/project/${encodedProjectCode}`);
|
||||
const files = response.data || [];
|
||||
setBomFiles(files);
|
||||
setGroupedFiles(groupFilesByBOM(files));
|
||||
|
||||
} catch (err) {
|
||||
console.error('파일 삭제 실패:', err);
|
||||
setError('파일 삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 리비전 업로드
|
||||
const handleRevisionUpload = (parentFile) => {
|
||||
setRevisionDialog({
|
||||
open: true,
|
||||
file: parentFile
|
||||
});
|
||||
};
|
||||
|
||||
// 리비전 업로드 성공 핸들러
|
||||
const handleRevisionUploadSuccess = () => {
|
||||
setRevisionDialog({ open: false, file: null });
|
||||
// BOM 파일 목록 새로고침
|
||||
loadBOMFiles();
|
||||
};
|
||||
|
||||
// 파일 업로드 처리
|
||||
const handleFileUpload = async (file) => {
|
||||
if (!file || !revisionDialog.file) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('job_no', selectedProject.job_no);
|
||||
formData.append('parent_file_id', revisionDialog.file.id);
|
||||
formData.append('bom_name', revisionDialog.file.bom_name || revisionDialog.file.original_filename);
|
||||
|
||||
const response = await api.post('/files/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
alert(`리비전 업로드 성공! ${response.data.revision}`);
|
||||
handleRevisionUploadSuccess();
|
||||
} else {
|
||||
alert(response.data.message || '리비전 업로드에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('리비전 업로드 실패:', error);
|
||||
alert('리비전 업로드에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 포맷팅
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('ko-KR');
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '60px',
|
||||
textAlign: 'center',
|
||||
color: '#6b7280'
|
||||
}}>
|
||||
<div style={{ fontSize: '24px', marginBottom: '16px' }}>⏳</div>
|
||||
<div>Loading BOM files...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '40px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#fee2e2',
|
||||
border: '1px solid #fecaca',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
color: '#dc2626'
|
||||
}}>
|
||||
<div style={{ fontSize: '20px', marginBottom: '8px' }}>⚠️</div>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (bomFiles.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '60px',
|
||||
textAlign: 'center',
|
||||
color: '#6b7280'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📄</div>
|
||||
<h3 style={{ fontSize: '20px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
No BOM Files Found
|
||||
</h3>
|
||||
<p style={{ fontSize: '16px', margin: 0 }}>
|
||||
Upload your first BOM file using the Upload tab
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '40px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
BOM Files & Revisions
|
||||
</h2>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#64748b',
|
||||
margin: 0
|
||||
}}>
|
||||
Select a BOM file to manage its materials
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#1d4ed8'
|
||||
}}>
|
||||
{Object.keys(groupedFiles).length} BOM Groups • {bomFiles.length} Total Files
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOM 파일 그룹 목록 */}
|
||||
<div style={{ display: 'grid', gap: '24px' }}>
|
||||
{Object.entries(groupedFiles).map(([bomName, files]) => {
|
||||
const latestFile = files[0]; // 최신 리비전
|
||||
const isSelected = selectedBOM?.id === latestFile.id;
|
||||
|
||||
return (
|
||||
<div key={bomName} style={{
|
||||
background: isSelected ? '#eff6ff' : 'white',
|
||||
border: isSelected ? '2px solid #3b82f6' : '1px solid #e5e7eb',
|
||||
borderRadius: '16px',
|
||||
padding: '24px',
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => handleBOMClick(latestFile)}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h3 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: isSelected ? '#1d4ed8' : '#374151',
|
||||
margin: '0 0 8px 0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<span style={{ fontSize: '24px' }}>📋</span>
|
||||
{bomName}
|
||||
{isSelected && (
|
||||
<span style={{
|
||||
background: '#3b82f6',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
SELECTED
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gap: '12px',
|
||||
fontSize: '14px',
|
||||
color: '#6b7280'
|
||||
}}>
|
||||
<div>
|
||||
<span style={{ fontWeight: '500' }}>Latest:</span> {latestFile.revision || 'Rev.0'}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontWeight: '500' }}>Revisions:</span> {Math.max(0, files.length - 1)}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontWeight: '500' }}>Updated:</span> {formatDate(latestFile.upload_date)}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontWeight: '500' }}>Size:</span> {latestFile.file_size ? `${Math.round(latestFile.file_size / 1024)} KB` : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginLeft: '16px' }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRevisionUpload(latestFile);
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'white',
|
||||
color: '#f59e0b',
|
||||
border: '1px solid #f59e0b',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
📝 New Revision
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteFile(latestFile.id, bomName);
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: '#fee2e2',
|
||||
color: '#dc2626',
|
||||
border: '1px solid #fecaca',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리비전 히스토리 */}
|
||||
{files.length > 1 && (
|
||||
<div style={{
|
||||
background: '#f8fafc',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
marginTop: '16px'
|
||||
}}>
|
||||
<h4 style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
Revision History
|
||||
</h4>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{files.map((file, index) => (
|
||||
<div key={file.id} style={{
|
||||
background: index === 0 ? '#dbeafe' : 'white',
|
||||
color: index === 0 ? '#1d4ed8' : '#6b7280',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{file.revision || 'Rev.0'}
|
||||
{index === 0 && ' (Latest)'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선택 안내 */}
|
||||
{!isSelected && (
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
background: 'rgba(59, 130, 246, 0.05)',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center',
|
||||
fontSize: '14px',
|
||||
color: '#3b82f6',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
Click to select this BOM for material management
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 향후 기능 안내 */}
|
||||
<div style={{
|
||||
marginTop: '40px',
|
||||
padding: '24px',
|
||||
background: '#f8fafc',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
🚧 Coming Soon: Advanced Revision Features
|
||||
</h3>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', marginBottom: '8px' }}>📊</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
|
||||
Visual Timeline
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6b7280' }}>
|
||||
Interactive revision history
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', marginBottom: '8px' }}>🔍</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
|
||||
Diff Comparison
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6b7280' }}>
|
||||
Compare changes between revisions
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', marginBottom: '8px' }}>⏪</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
|
||||
Rollback System
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6b7280' }}>
|
||||
Restore previous versions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리비전 업로드 다이얼로그 */}
|
||||
{revisionDialog.open && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
maxWidth: '500px',
|
||||
width: '90%',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600' }}>
|
||||
📝 리비전 업로드: {revisionDialog.file?.original_filename || 'BOM 파일'}
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: '16px', fontSize: '14px', color: '#6b7280' }}>
|
||||
새로운 리비전 파일을 선택해주세요.
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginBottom: '16px',
|
||||
padding: '8px',
|
||||
border: '2px dashed #d1d5db',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setRevisionDialog({ open: false, file: null })}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#f3f4f6',
|
||||
color: '#374151',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMFilesTab;
|
||||
105
tkeg/web/src/components/bom/tabs/BOMMaterialsTab.jsx
Normal file
105
tkeg/web/src/components/bom/tabs/BOMMaterialsTab.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import BOMManagementPage from '../../../pages/BOMManagementPage';
|
||||
|
||||
const BOMMaterialsTab = ({
|
||||
selectedProject,
|
||||
user,
|
||||
selectedBOM,
|
||||
onNavigate
|
||||
}) => {
|
||||
// BOMManagementPage에 필요한 props 구성
|
||||
const bomManagementProps = {
|
||||
onNavigate,
|
||||
user,
|
||||
selectedProject,
|
||||
fileId: selectedBOM?.id,
|
||||
jobNo: selectedBOM?.job_no || selectedProject?.official_project_code || selectedProject?.job_no,
|
||||
bomName: selectedBOM?.bom_name || selectedBOM?.original_filename,
|
||||
revision: selectedBOM?.revision || 'Rev.0',
|
||||
filename: selectedBOM?.original_filename
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
minHeight: '600px'
|
||||
}}>
|
||||
{/* 헤더 정보 */}
|
||||
<div style={{
|
||||
padding: '24px 40px',
|
||||
borderBottom: '1px solid #e5e7eb',
|
||||
background: '#f8fafc'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
Material Management
|
||||
</h2>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#64748b',
|
||||
margin: 0
|
||||
}}>
|
||||
BOM: {selectedBOM?.bom_name || selectedBOM?.original_filename} • {selectedBOM?.revision || 'Rev.0'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: '12px',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#1d4ed8' }}>
|
||||
{selectedBOM?.id || 'N/A'}
|
||||
</div>
|
||||
<div style={{ color: '#1d4ed8', fontWeight: '500' }}>
|
||||
File ID
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#059669' }}>
|
||||
{selectedBOM?.revision || 'Rev.0'}
|
||||
</div>
|
||||
<div style={{ color: '#059669', fontWeight: '500' }}>
|
||||
Revision
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOM 관리 페이지 임베드 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
// BOMManagementPage의 기본 패딩을 제거하기 위한 스타일 오버라이드
|
||||
'& > div': {
|
||||
padding: '0 !important',
|
||||
background: 'transparent !important',
|
||||
minHeight: 'auto !important'
|
||||
}
|
||||
}}>
|
||||
<BOMManagementPage {...bomManagementProps} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMMaterialsTab;
|
||||
494
tkeg/web/src/components/bom/tabs/BOMUploadTab.jsx
Normal file
494
tkeg/web/src/components/bom/tabs/BOMUploadTab.jsx
Normal file
@@ -0,0 +1,494 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import api from '../../../api';
|
||||
|
||||
const BOMUploadTab = ({
|
||||
selectedProject,
|
||||
user,
|
||||
onUploadSuccess,
|
||||
onNavigate
|
||||
}) => {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [selectedFiles, setSelectedFiles] = useState([]);
|
||||
const [bomName, setBomName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
// 파일 검증
|
||||
const validateFile = (file) => {
|
||||
const allowedTypes = [
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/csv'
|
||||
];
|
||||
|
||||
const maxSize = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
if (!allowedTypes.includes(file.type) && !file.name.match(/\.(xlsx|xls|csv)$/i)) {
|
||||
return '지원되지 않는 파일 형식입니다. Excel 또는 CSV 파일만 업로드 가능합니다.';
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
return '파일 크기가 너무 큽니다. 50MB 이하의 파일만 업로드 가능합니다.';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 파일 선택 처리
|
||||
const handleFileSelect = useCallback((files) => {
|
||||
const fileList = Array.from(files);
|
||||
const validFiles = [];
|
||||
const errors = [];
|
||||
|
||||
fileList.forEach(file => {
|
||||
const error = validateFile(file);
|
||||
if (error) {
|
||||
errors.push(`${file.name}: ${error}`);
|
||||
} else {
|
||||
validFiles.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
setError(errors.join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFiles(validFiles);
|
||||
setError('');
|
||||
|
||||
// 첫 번째 파일명을 기본 BOM 이름으로 설정 (확장자 제거)
|
||||
if (validFiles.length > 0 && !bomName) {
|
||||
const fileName = validFiles[0].name;
|
||||
const nameWithoutExt = fileName.replace(/\.[^/.]+$/, '');
|
||||
setBomName(nameWithoutExt);
|
||||
}
|
||||
}, [bomName]);
|
||||
|
||||
// 드래그 앤 드롭 처리
|
||||
const handleDragOver = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
handleFileSelect(e.dataTransfer.files);
|
||||
}, [handleFileSelect]);
|
||||
|
||||
// 파일 선택 버튼 클릭
|
||||
const handleFileButtonClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
// 파일 업로드
|
||||
const handleUpload = async () => {
|
||||
if (selectedFiles.length === 0) {
|
||||
setError('업로드할 파일을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bomName.trim()) {
|
||||
setError('BOM 이름을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedProject) {
|
||||
setError('프로젝트를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
let uploadedFile = null;
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
const file = selectedFiles[i];
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('file', file);
|
||||
formData.append('job_no', selectedProject.official_project_code || selectedProject.job_no);
|
||||
formData.append('bom_name', bomName.trim());
|
||||
formData.append('revision', 'Rev.0'); // 새 업로드는 항상 Rev.0
|
||||
|
||||
const response = await api.post('/files/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const progress = Math.round(
|
||||
((i * 100) + (progressEvent.loaded * 100) / progressEvent.total) / selectedFiles.length
|
||||
);
|
||||
setUploadProgress(progress);
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.data?.success) {
|
||||
throw new Error(response.data?.message || '업로드 실패');
|
||||
}
|
||||
|
||||
// 첫 번째 파일의 정보를 저장
|
||||
if (i === 0) {
|
||||
uploadedFile = {
|
||||
id: response.data.file_id,
|
||||
bom_name: bomName.trim(),
|
||||
revision: 'Rev.0',
|
||||
job_no: selectedProject.official_project_code || selectedProject.job_no,
|
||||
original_filename: file.name
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setSuccess(`${selectedFiles.length}개 파일이 성공적으로 업로드되었습니다!`);
|
||||
|
||||
// 업로드 성공 즉시 콜백 호출 (파일 목록 새로고침)
|
||||
if (onUploadSuccess) {
|
||||
onUploadSuccess(uploadedFile);
|
||||
}
|
||||
|
||||
// 파일 초기화
|
||||
setSelectedFiles([]);
|
||||
setBomName('');
|
||||
|
||||
} catch (err) {
|
||||
console.error('업로드 실패:', err);
|
||||
setError(`업로드 실패: ${err.response?.data?.detail || err.message}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 제거
|
||||
const removeFile = (index) => {
|
||||
const newFiles = selectedFiles.filter((_, i) => i !== index);
|
||||
setSelectedFiles(newFiles);
|
||||
|
||||
if (newFiles.length === 0) {
|
||||
setBomName('');
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 크기 포맷팅
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '40px' }}>
|
||||
{/* BOM 이름 입력 */}
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
BOM Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bomName}
|
||||
onChange={(e) => setBomName(e.target.value)}
|
||||
placeholder="Enter BOM name..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
fontSize: '16px',
|
||||
transition: 'border-color 0.2s ease',
|
||||
outline: 'none'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = '#e5e7eb'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 파일 드롭 영역 */}
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
style={{
|
||||
border: `3px dashed ${dragOver ? '#3b82f6' : '#d1d5db'}`,
|
||||
borderRadius: '16px',
|
||||
padding: '60px 40px',
|
||||
textAlign: 'center',
|
||||
background: dragOver ? '#eff6ff' : '#f9fafb',
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '24px'
|
||||
}}
|
||||
onClick={handleFileButtonClick}
|
||||
>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>
|
||||
{dragOver ? '📁' : '📄'}
|
||||
</div>
|
||||
<h3 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
{dragOver ? 'Drop files here' : 'Upload BOM Files'}
|
||||
</h3>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#6b7280',
|
||||
margin: '0 0 16px 0'
|
||||
}}>
|
||||
Drag and drop your Excel or CSV files here, or click to browse
|
||||
</p>
|
||||
<div style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 16px',
|
||||
background: 'rgba(59, 130, 246, 0.1)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#3b82f6'
|
||||
}}>
|
||||
<span>📋</span>
|
||||
Supported: .xlsx, .xls, .csv (Max 50MB)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 숨겨진 파일 입력 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{/* 선택된 파일 목록 */}
|
||||
{selectedFiles.length > 0 && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
Selected Files ({selectedFiles.length})
|
||||
</h4>
|
||||
<div style={{
|
||||
background: '#f8fafc',
|
||||
borderRadius: '12px',
|
||||
padding: '16px'
|
||||
}}>
|
||||
{selectedFiles.map((file, index) => (
|
||||
<div key={index} style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
marginBottom: index < selectedFiles.length - 1 ? '8px' : '0',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{ fontSize: '20px' }}>📄</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: '500', color: '#374151' }}>
|
||||
{file.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280' }}>
|
||||
{formatFileSize(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFile(index)}
|
||||
style={{
|
||||
background: '#fee2e2',
|
||||
color: '#dc2626',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드 진행률 */}
|
||||
{uploading && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
|
||||
Uploading...
|
||||
</span>
|
||||
<span style={{ fontSize: '14px', fontWeight: '500', color: '#3b82f6' }}>
|
||||
{uploadProgress}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '8px',
|
||||
background: '#e5e7eb',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${uploadProgress}%`,
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fee2e2',
|
||||
border: '1px solid #fecaca',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '16px' }}>⚠️</span>
|
||||
<div style={{ fontSize: '14px', color: '#dc2626', whiteSpace: 'pre-line' }}>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 성공 메시지 */}
|
||||
{success && (
|
||||
<div style={{
|
||||
background: '#dcfce7',
|
||||
border: '1px solid #bbf7d0',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '16px' }}>✅</span>
|
||||
<div style={{ fontSize: '14px', color: '#059669' }}>
|
||||
{success}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드 버튼 */}
|
||||
<div style={{ display: 'flex', gap: '16px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading || selectedFiles.length === 0 || !bomName.trim()}
|
||||
style={{
|
||||
padding: '12px 32px',
|
||||
background: (uploading || selectedFiles.length === 0 || !bomName.trim())
|
||||
? '#d1d5db'
|
||||
: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
cursor: (uploading || selectedFiles.length === 0 || !bomName.trim())
|
||||
? 'not-allowed'
|
||||
: 'pointer',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload BOM'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 가이드 정보 */}
|
||||
<div style={{
|
||||
marginTop: '40px',
|
||||
padding: '24px',
|
||||
background: '#f8fafc',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
📋 Upload Guidelines
|
||||
</h3>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||
gap: '20px'
|
||||
}}>
|
||||
<div>
|
||||
<h4 style={{ fontSize: '14px', fontWeight: '600', color: '#059669', marginBottom: '8px' }}>
|
||||
✅ Supported Formats
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '16px', color: '#6b7280', fontSize: '14px' }}>
|
||||
<li>Excel files (.xlsx, .xls)</li>
|
||||
<li>CSV files (.csv)</li>
|
||||
<li>Maximum file size: 50MB</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ fontSize: '14px', fontWeight: '600', color: '#3b82f6', marginBottom: '8px' }}>
|
||||
📊 Required Columns
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '16px', color: '#6b7280', fontSize: '14px' }}>
|
||||
<li>Description (자재명/품명)</li>
|
||||
<li>Quantity (수량)</li>
|
||||
<li>Size information (사이즈)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ fontSize: '14px', fontWeight: '600', color: '#f59e0b', marginBottom: '8px' }}>
|
||||
⚡ Auto Processing
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '16px', color: '#6b7280', fontSize: '14px' }}>
|
||||
<li>Automatic material classification</li>
|
||||
<li>WELD GAP items excluded</li>
|
||||
<li>Ready for material management</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMUploadTab;
|
||||
163
tkeg/web/src/components/common/ErrorBoundary.jsx
Normal file
163
tkeg/web/src/components/common/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React from 'react';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
this.setState({
|
||||
error: error,
|
||||
errorInfo: errorInfo
|
||||
});
|
||||
|
||||
// 에러 로깅
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
|
||||
// 에러 컨텍스트 정보 로깅
|
||||
if (this.props.errorContext) {
|
||||
console.error('Error context:', this.props.errorContext);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
|
||||
padding: '40px'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '20px',
|
||||
padding: '40px',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
textAlign: 'center',
|
||||
maxWidth: '600px'
|
||||
}}>
|
||||
<div style={{ fontSize: '64px', marginBottom: '24px' }}>⚠️</div>
|
||||
|
||||
<h2 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#dc2626',
|
||||
margin: '0 0 16px 0',
|
||||
letterSpacing: '-0.025em'
|
||||
}}>
|
||||
Something went wrong
|
||||
</h2>
|
||||
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#64748b',
|
||||
marginBottom: '32px',
|
||||
lineHeight: '1.6'
|
||||
}}>
|
||||
An unexpected error occurred. Please try refreshing the page or contact support if the problem persists.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '16px', justifyContent: 'center' }}>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 24px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.transform = 'translateY(-2px)';
|
||||
e.target.style.boxShadow = '0 8px 25px 0 rgba(59, 130, 246, 0.5)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.transform = 'translateY(0)';
|
||||
e.target.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false, error: null, errorInfo: null })}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 24px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = '#f9fafb';
|
||||
e.target.style.borderColor = '#9ca3af';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = 'white';
|
||||
e.target.style.borderColor = '#d1d5db';
|
||||
}}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 개발 환경에서만 에러 상세 정보 표시 */}
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<details style={{
|
||||
marginTop: '32px',
|
||||
textAlign: 'left',
|
||||
background: '#f8fafc',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<summary style={{
|
||||
cursor: 'pointer',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
Error Details (Development)
|
||||
</summary>
|
||||
<pre style={{
|
||||
fontSize: '12px',
|
||||
color: '#dc2626',
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}}>
|
||||
{this.state.error && this.state.error.toString()}
|
||||
<br />
|
||||
{this.state.errorInfo.componentStack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
170
tkeg/web/src/components/common/UserMenu.jsx
Normal file
170
tkeg/web/src/components/common/UserMenu.jsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React, { useState } from 'react';
|
||||
import { config } from '../../config';
|
||||
|
||||
const UserMenu = ({ user, onNavigate, onLogout }) => {
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
background: '#f8f9fa',
|
||||
border: '1px solid #e9ecef',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#495057',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = '#e9ecef';
|
||||
e.target.style.borderColor = '#dee2e6';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = '#f8f9fa';
|
||||
e.target.style.borderColor = '#e9ecef';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{(user?.name || user?.username || 'U').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||
{user?.name || user?.username}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||
{user?.role === 'system' ? '시스템 관리자' :
|
||||
user?.role === 'admin' ? '관리자' : '사용자'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#6c757d',
|
||||
transform: showUserMenu ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s ease'
|
||||
}}>
|
||||
▼
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
right: 0,
|
||||
marginTop: '8px',
|
||||
background: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
zIndex: 1050,
|
||||
minWidth: '200px'
|
||||
}}>
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
<div style={{ padding: '8px 16px', borderBottom: '1px solid #f1f3f4' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||
{user?.name || user?.username}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||
{user?.department || user?.role || ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
window.open(config.tkuserUrl, '_blank');
|
||||
setShowUserMenu(false);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
|
||||
onMouseLeave={(e) => e.target.style.background = 'none'}
|
||||
>
|
||||
⚙️ 계정 관리 (tkuser)
|
||||
</button>
|
||||
|
||||
{(user?.role === 'admin' || user?.role === 'system') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onNavigate('system-settings');
|
||||
setShowUserMenu(false);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
|
||||
onMouseLeave={(e) => e.target.style.background = 'none'}
|
||||
>
|
||||
🔧 시스템 설정
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div style={{ borderTop: '1px solid #f1f3f4', marginTop: '4px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
onLogout();
|
||||
setShowUserMenu(false);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#dc3545',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
|
||||
onMouseLeave={(e) => e.target.style.background = 'none'}
|
||||
>
|
||||
🚪 로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserMenu;
|
||||
3
tkeg/web/src/components/common/index.js
Normal file
3
tkeg/web/src/components/common/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// Common Components
|
||||
export { default as UserMenu } from './UserMenu';
|
||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||
541
tkeg/web/src/components/revision/RevisionManagementPanel.jsx
Normal file
541
tkeg/web/src/components/revision/RevisionManagementPanel.jsx
Normal file
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* 리비전 관리 패널 컴포넌트
|
||||
* BOM 관리 페이지에 통합되어 리비전 기능을 제공
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRevisionManagement } from '../../hooks/useRevisionManagement';
|
||||
|
||||
const RevisionManagementPanel = ({
|
||||
jobNo,
|
||||
currentFileId,
|
||||
previousFileId,
|
||||
onRevisionComplete,
|
||||
onRevisionCancel
|
||||
}) => {
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
currentSession,
|
||||
sessionStatus,
|
||||
createRevisionSession,
|
||||
getSessionStatus,
|
||||
compareCategory,
|
||||
getSessionChanges,
|
||||
processRevisionAction,
|
||||
completeSession,
|
||||
cancelSession,
|
||||
getRevisionSummary,
|
||||
getSupportedCategories,
|
||||
clearError
|
||||
} = useRevisionManagement();
|
||||
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||
const [categoryChanges, setCategoryChanges] = useState({});
|
||||
const [revisionSummary, setRevisionSummary] = useState(null);
|
||||
const [processingActions, setProcessingActions] = useState(new Set());
|
||||
|
||||
// 컴포넌트 초기화
|
||||
useEffect(() => {
|
||||
initializeRevisionPanel();
|
||||
}, [currentFileId, previousFileId]);
|
||||
|
||||
// 세션 상태 모니터링
|
||||
useEffect(() => {
|
||||
if (currentSession?.session_id) {
|
||||
const interval = setInterval(() => {
|
||||
refreshSessionStatus();
|
||||
}, 5000); // 5초마다 상태 갱신
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [currentSession]);
|
||||
|
||||
const initializeRevisionPanel = async () => {
|
||||
try {
|
||||
// 지원 카테고리 로드
|
||||
const categoriesResult = await getSupportedCategories();
|
||||
if (categoriesResult.success) {
|
||||
setCategories(categoriesResult.data);
|
||||
}
|
||||
|
||||
// 리비전 세션 생성
|
||||
if (currentFileId && previousFileId) {
|
||||
const sessionResult = await createRevisionSession(jobNo, currentFileId, previousFileId);
|
||||
if (sessionResult.success) {
|
||||
console.log('✅ 리비전 세션 생성 완료');
|
||||
await refreshSessionStatus();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('리비전 패널 초기화 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSessionStatus = async () => {
|
||||
if (currentSession?.session_id) {
|
||||
try {
|
||||
await getSessionStatus(currentSession.session_id);
|
||||
await loadRevisionSummary();
|
||||
} catch (error) {
|
||||
console.error('세션 상태 갱신 실패:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadRevisionSummary = async () => {
|
||||
if (currentSession?.session_id) {
|
||||
try {
|
||||
const summaryResult = await getRevisionSummary(currentSession.session_id);
|
||||
if (summaryResult.success) {
|
||||
setRevisionSummary(summaryResult.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('리비전 요약 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryCompare = async (category) => {
|
||||
if (!currentSession?.session_id) return;
|
||||
|
||||
try {
|
||||
const result = await compareCategory(currentSession.session_id, category);
|
||||
if (result.success) {
|
||||
// 변경사항 로드
|
||||
const changesResult = await getSessionChanges(currentSession.session_id, category);
|
||||
if (changesResult.success) {
|
||||
setCategoryChanges(prev => ({
|
||||
...prev,
|
||||
[category]: changesResult.data.changes
|
||||
}));
|
||||
}
|
||||
|
||||
await refreshSessionStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`카테고리 ${category} 비교 실패:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActionProcess = async (changeId, action, notes = '') => {
|
||||
setProcessingActions(prev => new Set(prev).add(changeId));
|
||||
|
||||
try {
|
||||
const result = await processRevisionAction(changeId, action, notes);
|
||||
if (result.success) {
|
||||
// 해당 카테고리 변경사항 새로고침
|
||||
if (selectedCategory) {
|
||||
const changesResult = await getSessionChanges(currentSession.session_id, selectedCategory);
|
||||
if (changesResult.success) {
|
||||
setCategoryChanges(prev => ({
|
||||
...prev,
|
||||
[selectedCategory]: changesResult.data.changes
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
await refreshSessionStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('액션 처리 실패:', error);
|
||||
} finally {
|
||||
setProcessingActions(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(changeId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteRevision = async () => {
|
||||
if (!currentSession?.session_id) return;
|
||||
|
||||
try {
|
||||
const result = await completeSession(currentSession.session_id);
|
||||
if (result.success) {
|
||||
onRevisionComplete?.(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('리비전 완료 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelRevision = async (reason = '') => {
|
||||
if (!currentSession?.session_id) return;
|
||||
|
||||
try {
|
||||
const result = await cancelSession(currentSession.session_id, reason);
|
||||
if (result.success) {
|
||||
onRevisionCancel?.(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('리비전 취소 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getActionColor = (action) => {
|
||||
const colors = {
|
||||
'new_material': '#10b981',
|
||||
'additional_purchase': '#f59e0b',
|
||||
'inventory_transfer': '#8b5cf6',
|
||||
'purchase_cancel': '#ef4444',
|
||||
'quantity_update': '#3b82f6',
|
||||
'maintain': '#6b7280'
|
||||
};
|
||||
return colors[action] || '#6b7280';
|
||||
};
|
||||
|
||||
const getActionLabel = (action) => {
|
||||
const labels = {
|
||||
'new_material': '신규 자재',
|
||||
'additional_purchase': '추가 구매',
|
||||
'inventory_transfer': '재고 이관',
|
||||
'purchase_cancel': '구매 취소',
|
||||
'quantity_update': '수량 업데이트',
|
||||
'maintain': '유지'
|
||||
};
|
||||
return labels[action] || action;
|
||||
};
|
||||
|
||||
if (!currentSession) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
background: '#f8fafc',
|
||||
borderRadius: '12px',
|
||||
border: '2px dashed #cbd5e1'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', color: '#64748b', marginBottom: '8px' }}>
|
||||
🔄 리비전 세션 초기화 중...
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#94a3b8' }}>
|
||||
자재 비교를 위한 세션을 준비하고 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
padding: '20px 24px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: '20px', fontWeight: '600' }}>
|
||||
📊 리비전 관리
|
||||
</h3>
|
||||
<p style={{ margin: '4px 0 0 0', fontSize: '14px', opacity: 0.9 }}>
|
||||
Job: {jobNo} | 세션 ID: {currentSession.session_id}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handleCompleteRevision}
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: '#10b981',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
opacity: loading ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
✅ 완료
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCancelRevision('사용자 요청')}
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: '#ef4444',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
opacity: loading ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
❌ 취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
color: '#dc2626',
|
||||
padding: '12px 24px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span>⚠️ {error}</span>
|
||||
<button
|
||||
onClick={clearError}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#dc2626',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 진행 상황 */}
|
||||
{sessionStatus && (
|
||||
<div style={{ padding: '20px 24px', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: '16px',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#10b981' }}>
|
||||
{sessionStatus.session_info.added_count || 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b' }}>추가</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#ef4444' }}>
|
||||
{sessionStatus.session_info.removed_count || 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b' }}>제거</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#f59e0b' }}>
|
||||
{sessionStatus.session_info.changed_count || 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b' }}>변경</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#6b7280' }}>
|
||||
{sessionStatus.session_info.unchanged_count || 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b' }}>유지</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 진행률 바 */}
|
||||
<div style={{
|
||||
background: '#f1f5f9',
|
||||
borderRadius: '8px',
|
||||
height: '8px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, #10b981 0%, #3b82f6 100%)',
|
||||
height: '100%',
|
||||
width: `${sessionStatus.progress_percentage || 0}%`,
|
||||
transition: 'width 0.3s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
fontSize: '12px',
|
||||
color: '#64748b',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
진행률: {Math.round(sessionStatus.progress_percentage || 0)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 탭 */}
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
|
||||
gap: '8px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
{categories.map(category => {
|
||||
const hasChanges = revisionSummary?.category_summaries?.[category.key];
|
||||
const isActive = selectedCategory === category.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.key}
|
||||
onClick={() => {
|
||||
setSelectedCategory(category.key);
|
||||
if (!categoryChanges[category.key]) {
|
||||
handleCategoryCompare(category.key);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: isActive
|
||||
? 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)'
|
||||
: hasChanges
|
||||
? 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)'
|
||||
: 'white',
|
||||
color: isActive ? 'white' : hasChanges ? '#92400e' : '#64748b',
|
||||
border: isActive ? 'none' : '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{category.name}
|
||||
{hasChanges && (
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
right: '-4px',
|
||||
background: '#ef4444',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
fontSize: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
{hasChanges.total_changes}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 선택된 카테고리의 변경사항 */}
|
||||
{selectedCategory && categoryChanges[selectedCategory] && (
|
||||
<div style={{
|
||||
background: '#f8fafc',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
<h4 style={{
|
||||
margin: '0 0 16px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#1e293b'
|
||||
}}>
|
||||
{categories.find(c => c.key === selectedCategory)?.name} 변경사항
|
||||
</h4>
|
||||
|
||||
{categoryChanges[selectedCategory].map((change, index) => (
|
||||
<div
|
||||
key={change.id || index}
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
marginBottom: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#1e293b',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{change.material_description}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#64748b',
|
||||
display: 'flex',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<span>이전: {change.previous_quantity || 0}</span>
|
||||
<span>현재: {change.current_quantity || 0}</span>
|
||||
<span>차이: {change.quantity_difference > 0 ? '+' : ''}{change.quantity_difference}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span
|
||||
style={{
|
||||
background: getActionColor(change.revision_action),
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{getActionLabel(change.revision_action)}
|
||||
</span>
|
||||
|
||||
{change.action_status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleActionProcess(change.id, change.revision_action)}
|
||||
disabled={processingActions.has(change.id)}
|
||||
style={{
|
||||
background: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '11px',
|
||||
cursor: processingActions.has(change.id) ? 'not-allowed' : 'pointer',
|
||||
opacity: processingActions.has(change.id) ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
{processingActions.has(change.id) ? '처리중...' : '처리'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{change.action_status === 'completed' && (
|
||||
<span style={{
|
||||
color: '#10b981',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
✅ 완료
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RevisionManagementPanel;
|
||||
25
tkeg/web/src/config.js
Normal file
25
tkeg/web/src/config.js
Normal 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`,
|
||||
};
|
||||
318
tkeg/web/src/hooks/useRevisionManagement.js
Normal file
318
tkeg/web/src/hooks/useRevisionManagement.js
Normal 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
34
tkeg/web/src/index.css
Normal 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;
|
||||
}
|
||||
184
tkeg/web/src/pages/BOMManagementPage.css
Normal file
184
tkeg/web/src/pages/BOMManagementPage.css
Normal 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;
|
||||
}
|
||||
}
|
||||
613
tkeg/web/src/pages/BOMManagementPage.jsx
Normal file
613
tkeg/web/src/pages/BOMManagementPage.jsx
Normal 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;
|
||||
300
tkeg/web/src/pages/BOMStatusPage.jsx
Normal file
300
tkeg/web/src/pages/BOMStatusPage.jsx
Normal 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;
|
||||
398
tkeg/web/src/pages/InactiveProjectsPage.jsx
Normal file
398
tkeg/web/src/pages/InactiveProjectsPage.jsx
Normal 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;
|
||||
334
tkeg/web/src/pages/JobRegistrationPage.css
Normal file
334
tkeg/web/src/pages/JobRegistrationPage.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
359
tkeg/web/src/pages/JobRegistrationPage.jsx
Normal file
359
tkeg/web/src/pages/JobRegistrationPage.jsx
Normal 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;
|
||||
279
tkeg/web/src/pages/JobSelectionPage.jsx
Normal file
279
tkeg/web/src/pages/JobSelectionPage.jsx
Normal 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;
|
||||
528
tkeg/web/src/pages/LogMonitoringPage.jsx
Normal file
528
tkeg/web/src/pages/LogMonitoringPage.jsx
Normal 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;
|
||||
215
tkeg/web/src/pages/MainPage.css
Normal file
215
tkeg/web/src/pages/MainPage.css
Normal 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;
|
||||
}
|
||||
}
|
||||
85
tkeg/web/src/pages/MainPage.jsx
Normal file
85
tkeg/web/src/pages/MainPage.jsx
Normal 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>© 2025 Technical Korea. All rights reserved.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainPage;
|
||||
1193
tkeg/web/src/pages/NewMaterialsPage.css
Normal file
1193
tkeg/web/src/pages/NewMaterialsPage.css
Normal file
File diff suppressed because it is too large
Load Diff
2849
tkeg/web/src/pages/NewMaterialsPage.jsx
Normal file
2849
tkeg/web/src/pages/NewMaterialsPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
358
tkeg/web/src/pages/ProjectWorkspacePage.jsx
Normal file
358
tkeg/web/src/pages/ProjectWorkspacePage.jsx
Normal 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;
|
||||
497
tkeg/web/src/pages/ProjectsPage.jsx
Normal file
497
tkeg/web/src/pages/ProjectsPage.jsx
Normal 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;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
388
tkeg/web/src/pages/PurchaseBatchPage.jsx
Normal file
388
tkeg/web/src/pages/PurchaseBatchPage.jsx
Normal 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;
|
||||
211
tkeg/web/src/pages/PurchaseRequestPage.css
Normal file
211
tkeg/web/src/pages/PurchaseRequestPage.css
Normal 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;
|
||||
}
|
||||
419
tkeg/web/src/pages/PurchaseRequestPage.jsx
Normal file
419
tkeg/web/src/pages/PurchaseRequestPage.jsx
Normal 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;
|
||||
439
tkeg/web/src/pages/SystemLogsPage.jsx
Normal file
439
tkeg/web/src/pages/SystemLogsPage.jsx
Normal 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;
|
||||
664
tkeg/web/src/pages/SystemSettingsPage.jsx
Normal file
664
tkeg/web/src/pages/SystemSettingsPage.jsx
Normal 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;
|
||||
|
||||
509
tkeg/web/src/pages/SystemSetupPage.jsx
Normal file
509
tkeg/web/src/pages/SystemSetupPage.jsx
Normal 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;
|
||||
266
tkeg/web/src/pages/UnifiedBOMPage.jsx
Normal file
266
tkeg/web/src/pages/UnifiedBOMPage.jsx
Normal 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;
|
||||
323
tkeg/web/src/utils/errorLogger.js
Normal file
323
tkeg/web/src/utils/errorLogger.js
Normal 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);
|
||||
1315
tkeg/web/src/utils/excelExport.js
Normal file
1315
tkeg/web/src/utils/excelExport.js
Normal file
File diff suppressed because it is too large
Load Diff
161
tkeg/web/src/utils/purchaseCalculator.js
Normal file
161
tkeg/web/src/utils/purchaseCalculator.js
Normal 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)
|
||||
}));
|
||||
};
|
||||
Reference in New Issue
Block a user