feat: 자재 관리 페이지 대규모 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 파이프 수량 계산 로직 수정 (단관 개수가 아닌 실제 길이 기반 계산) - UI 전면 개편 (DevonThink 스타일의 간결하고 세련된 디자인) - 자재별 그룹핑 로직 개선: * 플랜지: 동일 사양별 그룹핑, WN 스케줄 표시, ORIFICE 풀네임 표시 * 피팅: 상세 타입 표시 (니플 길이, 엘보 각도/연결, 티 타입, 리듀서 타입 등) * 밸브: 동일 사양별 그룹핑, 타입/연결방식/압력 표시 * 볼트: 크기/재질/길이별 그룹핑 (8SET → 개별 집계) * 가스켓: 동일 사양별 그룹핑, 재질/상세내역/두께 분리 표시 * UNKNOWN: 원본 설명 전체 표시, 동일 항목 그룹핑 - 전체 카테고리 버튼 제거 (표시 복잡도 감소) - 카테고리별 동적 컬럼 헤더 및 레이아웃 적용
This commit is contained in:
@@ -3,6 +3,9 @@ server {
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
|
||||
# 🔧 요청 크기 제한 증가 (413 오류 해결)
|
||||
client_max_body_size 100M;
|
||||
|
||||
# SPA를 위한 설정 (React Router 등)
|
||||
location / {
|
||||
@@ -16,6 +19,10 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 프록시 요청 크기 제한 증가
|
||||
proxy_request_buffering off;
|
||||
client_max_body_size 100M;
|
||||
}
|
||||
|
||||
# 정적 파일 캐싱
|
||||
|
||||
BIN
frontend/public/img/login-bg.jpeg
Normal file
BIN
frontend/public/img/login-bg.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
BIN
frontend/public/img/logo.png
Normal file
BIN
frontend/public/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -1,13 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import SimpleLogin from './SimpleLogin';
|
||||
import NavigationMenu from './components/NavigationMenu';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import ProjectsPage from './pages/ProjectsPage';
|
||||
import BOMStatusPage from './pages/BOMStatusPage';
|
||||
import SimpleMaterialsPage from './pages/SimpleMaterialsPage';
|
||||
import MaterialComparisonPage from './pages/MaterialComparisonPage';
|
||||
import RevisionPurchasePage from './pages/RevisionPurchasePage';
|
||||
import JobSelectionPage from './pages/JobSelectionPage';
|
||||
import BOMWorkspacePage from './pages/BOMWorkspacePage';
|
||||
import NewMaterialsPage from './pages/NewMaterialsPage';
|
||||
import SystemSettingsPage from './pages/SystemSettingsPage';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
@@ -16,6 +11,7 @@ function App() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState('dashboard');
|
||||
const [pageParams, setPageParams] = useState({});
|
||||
const [selectedProject, setSelectedProject] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 저장된 토큰 확인
|
||||
@@ -28,6 +24,24 @@ function App() {
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
// 자재 목록 페이지로 이동 이벤트 리스너
|
||||
const handleNavigateToMaterials = (event) => {
|
||||
const { jobNo, revision, bomName, message, file_id } = event.detail;
|
||||
navigateToPage('materials', {
|
||||
jobNo: jobNo,
|
||||
revision: revision,
|
||||
bomName: bomName,
|
||||
message: message,
|
||||
file_id: file_id // file_id 추가
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('navigateToMaterials', handleNavigateToMaterials);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('navigateToMaterials', handleNavigateToMaterials);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 로그인 성공 시 호출될 함수
|
||||
@@ -54,152 +68,393 @@ function App() {
|
||||
setPageParams(params);
|
||||
};
|
||||
|
||||
// 핵심 기능만 제공
|
||||
const getCoreFeatures = () => {
|
||||
return [
|
||||
{
|
||||
id: 'bom',
|
||||
title: '📋 BOM 업로드 & 분류',
|
||||
description: '엑셀 파일 업로드 → 자동 분류 → 검토 → 자재 확인 → 엑셀 내보내기',
|
||||
color: '#4299e1'
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// 관리자 전용 기능
|
||||
const getAdminFeatures = () => {
|
||||
if (user?.role !== 'admin') return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'system-settings',
|
||||
title: '⚙️ 시스템 설정',
|
||||
description: '사용자 계정 관리',
|
||||
color: '#dc2626'
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// 페이지 렌더링 함수
|
||||
const renderCurrentPage = () => {
|
||||
console.log('현재 페이지:', currentPage, '페이지 파라미터:', pageParams);
|
||||
switch (currentPage) {
|
||||
case 'dashboard':
|
||||
return <DashboardPage user={user} />;
|
||||
case 'projects':
|
||||
return <ProjectsPage user={user} />;
|
||||
const coreFeatures = getCoreFeatures();
|
||||
const adminFeatures = getAdminFeatures();
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#f7fafc' }}>
|
||||
{/* 상단 헤더 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
padding: '16px 32px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: '700', color: '#2d3748', margin: 0 }}>
|
||||
🏭 TK-MP BOM 관리 시스템
|
||||
</h1>
|
||||
<p style={{ color: '#718096', fontSize: '14px', margin: '4px 0 0 0' }}>
|
||||
{user?.full_name || user?.username}님 환영합니다
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
background: '#e2e8f0',
|
||||
color: '#4a5568',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<div style={{ padding: '32px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
|
||||
{/* 프로젝트 선택 */}
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', marginBottom: '12px' }}>
|
||||
📁 프로젝트 선택
|
||||
</h2>
|
||||
<select
|
||||
value={selectedProject?.official_project_code || ''}
|
||||
onChange={(e) => {
|
||||
const projectCode = e.target.value;
|
||||
if (projectCode) {
|
||||
setSelectedProject({
|
||||
official_project_code: projectCode,
|
||||
project_name: e.target.options[e.target.selectedIndex].text.split(' - ')[1]
|
||||
});
|
||||
} else {
|
||||
setSelectedProject(null);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
background: 'white'
|
||||
}}
|
||||
>
|
||||
<option value="">프로젝트를 선택하세요</option>
|
||||
<option value="J24-001">J24-001 - 테스트 프로젝트 A</option>
|
||||
<option value="J24-002">J24-002 - 테스트 프로젝트 B</option>
|
||||
<option value="J24-003">J24-003 - 테스트 프로젝트 C</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 핵심 기능 */}
|
||||
{selectedProject && (
|
||||
<>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748', marginBottom: '16px' }}>
|
||||
📋 BOM 관리 워크플로우
|
||||
</h2>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
gap: '16px'
|
||||
}}>
|
||||
{coreFeatures.map((feature) => (
|
||||
<div
|
||||
key={feature.id}
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.07)',
|
||||
border: '1px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 12px rgba(0, 0, 0, 0.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.07)';
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', marginBottom: '12px' }}>
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p style={{ color: '#718096', marginBottom: '16px', fontSize: '14px' }}>
|
||||
{feature.description}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigateToPage(feature.id, { selectedProject })}
|
||||
style={{
|
||||
background: feature.color,
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '12px 20px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
시작하기
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 관리자 기능 (있는 경우만) */}
|
||||
{adminFeatures.length > 0 && (
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748', marginBottom: '16px' }}>
|
||||
⚙️ 시스템 관리
|
||||
</h2>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
gap: '16px'
|
||||
}}>
|
||||
{adminFeatures.map((feature) => (
|
||||
<div
|
||||
key={feature.id}
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.07)',
|
||||
border: '1px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 12px rgba(0, 0, 0, 0.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.07)';
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', marginBottom: '12px' }}>
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p style={{ color: '#718096', marginBottom: '16px', fontSize: '14px' }}>
|
||||
{feature.description}
|
||||
</p>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<span style={{
|
||||
background: '#fef7e0',
|
||||
color: '#92400e',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
관리자 전용
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigateToPage(feature.id)}
|
||||
style={{
|
||||
background: feature.color,
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '12px 20px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
관리하기
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 간단한 사용법 안내 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#2d3748', marginBottom: '16px' }}>
|
||||
📖 간단한 사용법
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
background: '#4299e1',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>1</span>
|
||||
<span style={{ fontSize: '14px', color: '#4a5568' }}>BOM 업로드</span>
|
||||
</div>
|
||||
<span style={{ color: '#a0aec0' }}>→</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
background: '#ed8936',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>2</span>
|
||||
<span style={{ fontSize: '14px', color: '#4a5568' }}>자동 분류</span>
|
||||
</div>
|
||||
<span style={{ color: '#a0aec0' }}>→</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
background: '#48bb78',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>3</span>
|
||||
<span style={{ fontSize: '14px', color: '#4a5568' }}>엑셀 내보내기</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)} {/* selectedProject 조건문 닫기 */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'bom':
|
||||
return <JobSelectionPage onJobSelect={(jobNo, jobName) =>
|
||||
navigateToPage('bom-status', { job_no: jobNo, job_name: jobName })
|
||||
} />;
|
||||
case 'bom-status':
|
||||
return <BOMStatusPage
|
||||
jobNo={pageParams.job_no}
|
||||
jobName={pageParams.job_name}
|
||||
onNavigate={navigateToPage}
|
||||
/>;
|
||||
return (
|
||||
<BOMWorkspacePage
|
||||
project={pageParams.selectedProject}
|
||||
onNavigate={navigateToPage}
|
||||
onBack={() => navigateToPage('dashboard')}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'materials':
|
||||
return <SimpleMaterialsPage
|
||||
fileId={pageParams.file_id}
|
||||
jobNo={pageParams.jobNo}
|
||||
bomName={pageParams.bomName}
|
||||
revision={pageParams.revision}
|
||||
filename={pageParams.filename}
|
||||
onNavigate={navigateToPage}
|
||||
/>;
|
||||
case 'material-comparison':
|
||||
return <MaterialComparisonPage
|
||||
jobNo={pageParams.job_no}
|
||||
currentRevision={pageParams.current_revision}
|
||||
previousRevision={pageParams.previous_revision}
|
||||
filename={pageParams.filename}
|
||||
onNavigate={navigateToPage}
|
||||
/>;
|
||||
case 'revision-purchase':
|
||||
return <RevisionPurchasePage
|
||||
jobNo={pageParams.job_no}
|
||||
revision={pageParams.revision}
|
||||
onNavigate={navigateToPage}
|
||||
/>;
|
||||
case 'quotes':
|
||||
return <div style={{ padding: '32px' }}>💰 견적 관리 페이지 (곧 구현 예정)</div>;
|
||||
case 'procurement':
|
||||
return <div style={{ padding: '32px' }}>🛒 구매 관리 페이지 (곧 구현 예정)</div>;
|
||||
case 'production':
|
||||
return <div style={{ padding: '32px' }}>🏭 생산 관리 페이지 (곧 구현 예정)</div>;
|
||||
case 'shipment':
|
||||
return <div style={{ padding: '32px' }}>🚚 출하 관리 페이지 (곧 구현 예정)</div>;
|
||||
case 'users':
|
||||
return <div style={{ padding: '32px' }}>👥 사용자 관리 페이지 (곧 구현 예정)</div>;
|
||||
case 'system':
|
||||
return <div style={{ padding: '32px' }}>⚙️ 시스템 설정 페이지 (곧 구현 예정)</div>;
|
||||
return (
|
||||
<NewMaterialsPage
|
||||
onNavigate={navigateToPage}
|
||||
selectedProject={pageParams.selectedProject}
|
||||
fileId={pageParams.file_id}
|
||||
jobNo={pageParams.jobNo}
|
||||
bomName={pageParams.bomName}
|
||||
revision={pageParams.revision}
|
||||
filename={pageParams.filename}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'system-settings':
|
||||
return (
|
||||
<SystemSettingsPage
|
||||
onNavigate={navigateToPage}
|
||||
user={user}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return <DashboardPage user={user} />;
|
||||
return (
|
||||
<div style={{ padding: '32px', textAlign: 'center' }}>
|
||||
<h2>페이지를 찾을 수 없습니다</h2>
|
||||
<button
|
||||
onClick={() => navigateToPage('dashboard')}
|
||||
style={{
|
||||
background: '#4299e1',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '12px 24px',
|
||||
cursor: 'pointer',
|
||||
marginTop: '16px'
|
||||
}}
|
||||
>
|
||||
대시보드로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 중
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: '#f7fafc'
|
||||
}}>
|
||||
<div>로딩 중...</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', marginBottom: '16px' }}>🔄</div>
|
||||
<div style={{ fontSize: '16px', color: '#718096' }}>로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 로그인하지 않은 경우
|
||||
if (!isAuthenticated) {
|
||||
return <SimpleLogin onLoginSuccess={handleLoginSuccess} />;
|
||||
}
|
||||
|
||||
// 메인 애플리케이션
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<NavigationMenu
|
||||
user={user}
|
||||
currentPage={currentPage}
|
||||
onPageChange={(page) => navigateToPage(page, {})}
|
||||
/>
|
||||
|
||||
{/* 메인 콘텐츠 영역 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
marginLeft: '280px', // 사이드바 너비만큼 여백
|
||||
background: '#f7fafc'
|
||||
}}>
|
||||
{/* 상단 헤더 */}
|
||||
<header style={{
|
||||
background: 'white',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
padding: '16px 32px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{
|
||||
margin: '0',
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
{currentPage === 'dashboard' && '대시보드'}
|
||||
{currentPage === 'projects' && '프로젝트 관리'}
|
||||
{currentPage === 'bom' && 'BOM 관리'}
|
||||
{currentPage === 'materials' && '자재 관리'}
|
||||
{currentPage === 'quotes' && '견적 관리'}
|
||||
{currentPage === 'procurement' && '구매 관리'}
|
||||
{currentPage === 'production' && '생산 관리'}
|
||||
{currentPage === 'shipment' && '출하 관리'}
|
||||
{currentPage === 'users' && '사용자 관리'}
|
||||
{currentPage === 'system' && '시스템 설정'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#e53e3e',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px'
|
||||
}}
|
||||
>
|
||||
<span>🚪</span>
|
||||
로그아웃
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* 페이지 콘텐츠 */}
|
||||
<main>
|
||||
{renderCurrentPage()}
|
||||
</main>
|
||||
</div>
|
||||
<div style={{ minHeight: '100vh', background: '#f7fafc' }}>
|
||||
{renderCurrentPage()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
@@ -218,3 +218,19 @@ const SimpleDashboard = () => {
|
||||
};
|
||||
|
||||
export default SimpleDashboard;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -61,146 +61,150 @@ const SimpleLogin = ({ onLoginSuccess }) => {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
background: 'url("/img/login-bg.jpeg") no-repeat center center fixed',
|
||||
backgroundSize: 'cover',
|
||||
fontFamily: 'Malgun Gothic, sans-serif',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: '20px',
|
||||
fontFamily: 'Arial, sans-serif'
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.1)',
|
||||
background: 'rgba(0, 0, 0, 0.65)',
|
||||
width: '400px',
|
||||
padding: '40px',
|
||||
width: '100%',
|
||||
maxWidth: '400px'
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center',
|
||||
color: 'white',
|
||||
boxShadow: '0 0 20px rgba(0,0,0,0.3)'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||
<h1 style={{ color: '#2d3748', fontSize: '28px', fontWeight: '700', margin: '0 0 8px 0' }}>
|
||||
🚀 TK-MP System
|
||||
</h1>
|
||||
<p style={{ color: '#718096', fontSize: '14px', margin: '0' }}>
|
||||
통합 프로젝트 관리 시스템
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
src="/img/logo.png"
|
||||
alt="테크니컬코리아 로고"
|
||||
style={{
|
||||
width: '200px',
|
||||
marginBottom: '20px'
|
||||
}}
|
||||
/>
|
||||
<h1 style={{
|
||||
color: 'white',
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
(주)테크니컬코리아
|
||||
</h1>
|
||||
<h3 style={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
fontSize: '18px',
|
||||
fontWeight: '400',
|
||||
margin: '0 0 32px 0'
|
||||
}}>
|
||||
통합 관리 시스템
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
color: '#2d3748',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
사용자명
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
placeholder="사용자명을 입력하세요"
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '2px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
color: '#2d3748',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '2px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '12px 16px',
|
||||
background: '#fed7d7',
|
||||
border: '1px solid #feb2b2',
|
||||
borderRadius: '8px',
|
||||
color: '#c53030',
|
||||
fontSize: '14px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<span>⚠️</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: '#c6f6d5',
|
||||
border: '1px solid #9ae6b4',
|
||||
borderRadius: '8px',
|
||||
color: '#2f855a',
|
||||
fontSize: '14px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
✅ {success}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
placeholder="아이디"
|
||||
disabled={isLoading}
|
||||
required
|
||||
autoComplete="username"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
margin: '15px 0',
|
||||
padding: '12px',
|
||||
fontSize: '1rem',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
placeholder="비밀번호"
|
||||
disabled={isLoading}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
margin: '15px 0',
|
||||
padding: '12px',
|
||||
fontSize: '1rem',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 24px',
|
||||
background: isLoading ? '#a0aec0' : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
padding: '12px 20px',
|
||||
fontSize: '1rem',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
marginTop: '8px'
|
||||
border: 'none',
|
||||
backgroundColor: isLoading ? '#a0aec0' : '#1976d2',
|
||||
color: 'white',
|
||||
borderRadius: '6px',
|
||||
transition: 'background-color 0.3s',
|
||||
width: '100%',
|
||||
marginTop: '10px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isLoading) {
|
||||
e.target.style.backgroundColor = '#1565c0';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isLoading) {
|
||||
e.target.style.backgroundColor = '#1976d2';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading ? '로그인 중...' : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
marginTop: '10px',
|
||||
color: '#ff6b6b',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div style={{
|
||||
marginTop: '10px',
|
||||
color: '#4caf50',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
✅ {success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '32px', textAlign: 'center' }}>
|
||||
<p style={{ color: '#718096', fontSize: '14px', margin: '0 0 16px 0' }}>
|
||||
테스트 계정: admin / admin123 또는 testuser / test123
|
||||
<p style={{ color: 'rgba(255, 255, 255, 0.8)', fontSize: '14px', margin: '0 0 16px 0' }}>
|
||||
테스트 계정: admin / admin123
|
||||
</p>
|
||||
<div>
|
||||
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||
TK-MP Project Management System v2.0
|
||||
<small style={{ color: 'rgba(255, 255, 255, 0.6)', fontSize: '12px' }}>
|
||||
TK-MP 통합 관리 시스템 v2.0
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const TestApp = () => {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
background: '#f7fafc',
|
||||
color: '#2d3748',
|
||||
fontFamily: 'Arial, sans-serif'
|
||||
}}>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px',
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
<h1>🚀 TK-MP System</h1>
|
||||
<p>시스템이 정상적으로 작동 중입니다!</p>
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<button
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: '#667eea',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => alert('테스트 성공!')}
|
||||
>
|
||||
테스트 버튼
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestApp;
|
||||
@@ -6,7 +6,8 @@ const BOMFileTable = ({
|
||||
groupFilesByBOM,
|
||||
handleViewMaterials,
|
||||
openRevisionDialog,
|
||||
handleDelete
|
||||
handleDelete,
|
||||
onNavigate
|
||||
}) => {
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -97,6 +98,32 @@ const BOMFileTable = ({
|
||||
</td>
|
||||
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
// 자재 관리 페이지로 이동 (특정 파일)
|
||||
if (window.onNavigate) {
|
||||
window.onNavigate('materials', {
|
||||
file_id: file.id,
|
||||
jobNo: file.job_no,
|
||||
bomName: file.bom_name,
|
||||
revision: file.revision,
|
||||
filename: file.original_filename
|
||||
});
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#667eea',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
📋 자재 보기
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleViewMaterials(file)}
|
||||
style={{
|
||||
@@ -112,22 +139,20 @@ const BOMFileTable = ({
|
||||
🧮 구매수량 계산
|
||||
</button>
|
||||
|
||||
{index === 0 && (
|
||||
<button
|
||||
onClick={() => openRevisionDialog(file.bom_name || bomKey, file.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: 'white',
|
||||
color: '#4299e1',
|
||||
border: '1px solid #4299e1',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
리비전
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openRevisionDialog(file.bom_name || bomKey, file.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: 'white',
|
||||
color: '#4299e1',
|
||||
border: '1px solid #4299e1',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
📝 리비전
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(file.id)}
|
||||
300
frontend/src/_deprecated/BOMStatusPage.jsx
Normal file
300
frontend/src/_deprecated/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;
|
||||
416
frontend/src/_deprecated/BOMUploadPage.jsx
Normal file
416
frontend/src/_deprecated/BOMUploadPage.jsx
Normal file
@@ -0,0 +1,416 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
const BOMUploadPage = ({ projectInfo, onNavigate, onBack }) => {
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [jobNo, setJobNo] = useState(projectInfo?.job_no || '');
|
||||
const [revision, setRevision] = useState('Rev.0');
|
||||
const [bomName, setBomName] = useState('');
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadResult, setUploadResult] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectInfo) {
|
||||
setJobNo(projectInfo.job_no);
|
||||
setBomName(projectInfo.project_name || '');
|
||||
}
|
||||
}, [projectInfo]);
|
||||
|
||||
const handleFileSelect = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
setUploadResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) {
|
||||
alert('파일을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!jobNo.trim()) {
|
||||
alert('Job 번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadResult(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('job_no', jobNo.trim());
|
||||
formData.append('revision', revision);
|
||||
if (bomName.trim()) {
|
||||
formData.append('bom_name', bomName.trim());
|
||||
}
|
||||
|
||||
const response = await api.post('/files/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setUploadResult({
|
||||
success: true,
|
||||
message: response.data.message,
|
||||
fileId: response.data.file_id,
|
||||
materialsCount: response.data.materials_count,
|
||||
revision: response.data.revision,
|
||||
uploadedBy: response.data.uploaded_by
|
||||
});
|
||||
|
||||
// 파일 선택 초기화
|
||||
setSelectedFile(null);
|
||||
const fileInput = document.getElementById('file-input');
|
||||
if (fileInput) fileInput.value = '';
|
||||
} else {
|
||||
setUploadResult({
|
||||
success: false,
|
||||
message: response.data.message || '업로드에 실패했습니다.'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('업로드 오류:', error);
|
||||
setUploadResult({
|
||||
success: false,
|
||||
message: error.response?.data?.detail || '업로드 중 오류가 발생했습니다.'
|
||||
});
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewMaterials = () => {
|
||||
if (uploadResult && uploadResult.fileId) {
|
||||
onNavigate('materials', {
|
||||
file_id: uploadResult.fileId,
|
||||
jobNo: jobNo,
|
||||
bomName: bomName,
|
||||
revision: uploadResult.revision,
|
||||
filename: selectedFile?.name
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
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'
|
||||
}}>
|
||||
📤 BOM 파일 업로드
|
||||
</h1>
|
||||
{projectInfo && (
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
{projectInfo.project_name} ({projectInfo.job_no})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 업로드 폼 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gap: '24px'
|
||||
}}>
|
||||
{/* Job 번호 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
Job 번호 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={jobNo}
|
||||
onChange={(e) => setJobNo(e.target.value)}
|
||||
placeholder="예: TK-2024-001"
|
||||
disabled={!!projectInfo?.job_no}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
backgroundColor: projectInfo?.job_no ? '#f9fafb' : 'white'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 리비전 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
리비전
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={revision}
|
||||
onChange={(e) => setRevision(e.target.value)}
|
||||
placeholder="예: Rev.0, Rev.1"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* BOM 이름 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
BOM 이름 (선택사항)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bomName}
|
||||
onChange={(e) => setBomName(e.target.value)}
|
||||
placeholder="BOM 파일 설명"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 파일 선택 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
BOM 파일 *
|
||||
</label>
|
||||
<div style={{
|
||||
border: '2px dashed #d1d5db',
|
||||
borderRadius: '12px',
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: selectedFile ? '#f0f9ff' : '#fafafa',
|
||||
borderColor: selectedFile ? '#3b82f6' : '#d1d5db'
|
||||
}}>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-input"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
display: 'block'
|
||||
}}
|
||||
>
|
||||
{selectedFile ? (
|
||||
<div>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📄</div>
|
||||
<div style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
{selectedFile.name}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#6b7280'
|
||||
}}>
|
||||
크기: {(selectedFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#3b82f6',
|
||||
marginTop: '8px'
|
||||
}}>
|
||||
다른 파일을 선택하려면 클릭하세요
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📁</div>
|
||||
<div style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
파일을 선택하거나 드래그하세요
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#6b7280'
|
||||
}}>
|
||||
Excel (.xlsx, .xls) 또는 CSV 파일만 지원됩니다
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 업로드 버튼 */}
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || !jobNo.trim() || isUploading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
backgroundColor: (!selectedFile || !jobNo.trim() || isUploading) ? '#9ca3af' : '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
cursor: (!selectedFile || !jobNo.trim() || isUploading) ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
{isUploading ? '업로드 중...' : '📤 업로드 시작'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 업로드 결과 */}
|
||||
{uploadResult && (
|
||||
<div style={{
|
||||
background: uploadResult.success ? '#f0f9ff' : '#fef2f2',
|
||||
border: `1px solid ${uploadResult.success ? '#3b82f6' : '#ef4444'}`,
|
||||
borderRadius: '12px',
|
||||
padding: '24px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<div style={{ fontSize: '24px' }}>
|
||||
{uploadResult.success ? '✅' : '❌'}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h3 style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: uploadResult.success ? '#1e40af' : '#dc2626'
|
||||
}}>
|
||||
{uploadResult.success ? '업로드 성공!' : '업로드 실패'}
|
||||
</h3>
|
||||
<p style={{
|
||||
margin: '0 0 16px 0',
|
||||
fontSize: '14px',
|
||||
color: '#374151',
|
||||
lineHeight: 1.5
|
||||
}}>
|
||||
{uploadResult.message}
|
||||
</p>
|
||||
|
||||
{uploadResult.success && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
marginTop: '16px'
|
||||
}}>
|
||||
<button
|
||||
onClick={handleViewMaterials}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
자재 목록 보기
|
||||
</button>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#6b7280',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
{uploadResult.materialsCount}개 자재 분류됨 • {uploadResult.uploadedBy}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMUploadPage;
|
||||
@@ -21,6 +21,20 @@ export const api = axios.create({
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 1000; // 1초
|
||||
|
||||
// 요청 인터셉터: 토큰 자동 추가
|
||||
api.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 재시도 함수
|
||||
const retryRequest = async (config, retries = MAX_RETRIES) => {
|
||||
try {
|
||||
@@ -35,7 +49,7 @@ const retryRequest = async (config, retries = MAX_RETRIES) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 공통 에러 핸들링 (예시)
|
||||
// 응답 인터셉터: 에러 처리 및 자동 로그아웃
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
@@ -47,7 +61,18 @@ api.interceptors.response.use(
|
||||
message: error.message
|
||||
});
|
||||
|
||||
// 필요시 에러 로깅/알림 등 추가
|
||||
// 401/403 에러 시 자동 로그아웃
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
console.log('토큰이 유효하지 않습니다. 자동 로그아웃 처리합니다.');
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('user_data');
|
||||
// 페이지 새로고침으로 로그인 페이지로 이동
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
@@ -65,9 +90,9 @@ export function uploadFile(formData, options = {}) {
|
||||
return retryRequest(config);
|
||||
}
|
||||
|
||||
// 예시: 자재 목록 조회
|
||||
// 예시: 자재 목록 조회 (신버전 API 사용)
|
||||
export function fetchMaterials(params) {
|
||||
return api.get('/files/materials', { params });
|
||||
return api.get('/files/materials-v2', { params });
|
||||
}
|
||||
|
||||
// 예시: 자재 요약 통계
|
||||
@@ -82,7 +107,7 @@ export function fetchFiles(params) {
|
||||
|
||||
// 파일 삭제
|
||||
export function deleteFile(fileId) {
|
||||
return api.delete(`/files/${fileId}`);
|
||||
return api.delete(`/files/delete/${fileId}`);
|
||||
}
|
||||
|
||||
// 예시: Job 목록 조회
|
||||
|
||||
@@ -116,3 +116,19 @@ const BOMFileUpload = ({
|
||||
};
|
||||
|
||||
export default BOMFileUpload;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -464,7 +464,18 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => window.location.href = '/materials'}
|
||||
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 />}
|
||||
>
|
||||
자재 목록 보기
|
||||
|
||||
@@ -761,11 +761,18 @@ function MaterialList({ selectedProject }) {
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Typography variant="h6" color="primary">
|
||||
{material.quantity.toLocaleString()}
|
||||
{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.unit} size="small" />
|
||||
<Chip label={material.classified_category === 'PIPE' ? '본' : material.unit} size="small" />
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
|
||||
@@ -535,3 +535,19 @@
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -268,3 +268,19 @@ const NavigationBar = ({ currentPage, onNavigate }) => {
|
||||
};
|
||||
|
||||
export default NavigationBar;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -248,3 +248,19 @@
|
||||
.menu-section::-webkit-scrollbar-thumb:hover {
|
||||
background: #a1a1a1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -172,3 +172,19 @@ const NavigationMenu = ({ user, currentPage, onPageChange }) => {
|
||||
};
|
||||
|
||||
export default NavigationMenu;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
482
frontend/src/components/PersonalizedDashboard.jsx
Normal file
482
frontend/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;
|
||||
319
frontend/src/components/ProjectSelector.jsx
Normal file
319
frontend/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;
|
||||
@@ -80,3 +80,19 @@ const RevisionUploadDialog = ({
|
||||
};
|
||||
|
||||
export default RevisionUploadDialog;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -299,3 +299,19 @@ const SimpleFileUpload = ({ selectedProject, onUploadComplete }) => {
|
||||
};
|
||||
|
||||
export default SimpleFileUpload;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,114 +1,70 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { uploadFile as uploadFileApi, fetchFiles as fetchFilesApi, deleteFile as deleteFileApi, api } from '../api';
|
||||
import { api, fetchFiles, deleteFile as deleteFileApi } from '../api';
|
||||
import BOMFileUpload from '../components/BOMFileUpload';
|
||||
import BOMFileTable from '../components/BOMFileTable';
|
||||
import RevisionUploadDialog from '../components/RevisionUploadDialog';
|
||||
|
||||
const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
|
||||
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('');
|
||||
const [revisionDialog, setRevisionDialog] = useState({ open: false, bomName: '', parentId: null });
|
||||
const [revisionFile, setRevisionFile] = useState(null);
|
||||
const [purchaseModal, setPurchaseModal] = useState({ open: false, data: null, fileInfo: null });
|
||||
|
||||
// 카테고리별 색상 함수
|
||||
const getCategoryColor = (category) => {
|
||||
const colors = {
|
||||
'pipe': '#4299e1',
|
||||
'fitting': '#48bb78',
|
||||
'valve': '#ed8936',
|
||||
'flange': '#9f7aea',
|
||||
'bolt': '#38b2ac',
|
||||
'gasket': '#f56565',
|
||||
'instrument': '#d69e2e',
|
||||
'material': '#718096',
|
||||
'integrated': '#319795',
|
||||
'unknown': '#a0aec0'
|
||||
};
|
||||
return colors[category?.toLowerCase()] || colors.unknown;
|
||||
};
|
||||
useEffect(() => {
|
||||
if (jobNo) {
|
||||
fetchFilesList();
|
||||
}
|
||||
}, [jobNo]);
|
||||
|
||||
// 파일 목록 불러오기
|
||||
const fetchFiles = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const fetchFilesList = async () => {
|
||||
try {
|
||||
console.log('fetchFiles 호출 - jobNo:', jobNo);
|
||||
const response = await fetchFilesApi({ job_no: jobNo });
|
||||
console.log('API 응답:', response);
|
||||
setLoading(true);
|
||||
const response = await api.get('/files/', {
|
||||
params: { job_no: jobNo }
|
||||
});
|
||||
|
||||
if (response.data && response.data.data && Array.isArray(response.data.data)) {
|
||||
setFiles(response.data.data);
|
||||
} else if (response.data && Array.isArray(response.data)) {
|
||||
// API가 배열로 직접 반환하는 경우
|
||||
if (Array.isArray(response.data)) {
|
||||
setFiles(response.data);
|
||||
} else if (response.data && Array.isArray(response.data.files)) {
|
||||
setFiles(response.data.files);
|
||||
} else if (response.data && response.data.success) {
|
||||
setFiles(response.data.files || []);
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('파일 목록 불러오기 실패:', err);
|
||||
console.error('파일 목록 로딩 실패:', err);
|
||||
setError('파일 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (jobNo) {
|
||||
fetchFiles();
|
||||
}
|
||||
}, [jobNo]);
|
||||
|
||||
// 파일 업로드
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile || !bomName.trim()) {
|
||||
setError('파일과 BOM 이름을 모두 입력해주세요.');
|
||||
alert('파일과 BOM 이름을 모두 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('job_no', jobNo);
|
||||
formData.append('bom_name', bomName.trim());
|
||||
formData.append('job_no', jobNo);
|
||||
|
||||
const uploadResult = await uploadFileApi(formData);
|
||||
|
||||
// 업로드 성공 후 파일 목록 새로고침
|
||||
await fetchFiles();
|
||||
|
||||
// 업로드 완료 후 자동으로 구매 수량 계산 실행
|
||||
if (uploadResult && uploadResult.file_id) {
|
||||
// 잠시 후 구매 수량 계산 페이지로 이동
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// 구매 수량 계산 API 호출
|
||||
const response = await fetch(`/api/purchase/calculate?job_no=${jobNo}&revision=Rev.0&file_id=${uploadResult.file_id}`);
|
||||
const purchaseData = await response.json();
|
||||
|
||||
if (purchaseData.success) {
|
||||
// 구매 수량 계산 결과를 모달로 표시하거나 별도 페이지로 이동
|
||||
alert(`업로드 및 분류 완료!\n구매 수량이 계산되었습니다.\n\n파이프: ${purchaseData.purchase_items?.filter(item => item.category === 'PIPE').length || 0}개 항목\n기타 자재: ${purchaseData.purchase_items?.filter(item => item.category !== 'PIPE').length || 0}개 항목`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('구매 수량 계산 실패:', error);
|
||||
}
|
||||
}, 2000); // 2초 후 실행 (분류 완료 대기)
|
||||
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 || '업로드 실패');
|
||||
}
|
||||
|
||||
// 폼 초기화
|
||||
setSelectedFile(null);
|
||||
setBomName('');
|
||||
document.getElementById('file-input').value = '';
|
||||
|
||||
} catch (err) {
|
||||
console.error('파일 업로드 실패:', err);
|
||||
setError('파일 업로드에 실패했습니다.');
|
||||
@@ -125,111 +81,26 @@ const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
|
||||
|
||||
try {
|
||||
await deleteFileApi(fileId);
|
||||
await fetchFiles(); // 목록 새로고침
|
||||
await fetchFilesList(); // 목록 새로고침
|
||||
} catch (err) {
|
||||
console.error('파일 삭제 실패:', err);
|
||||
setError('파일 삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 자재 확인 페이지로 이동
|
||||
// 구매 수량 계산 (자재 목록 페이지 거치지 않음)
|
||||
const handleViewMaterials = async (file) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 구매 수량 계산 API 호출
|
||||
console.log('구매 수량 계산 API 호출:', {
|
||||
job_no: file.job_no,
|
||||
revision: file.revision || 'Rev.0',
|
||||
file_id: file.id
|
||||
// 자재 관리 페이지로 바로 이동 (단순화)
|
||||
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
|
||||
});
|
||||
|
||||
const response = await api.get(`/purchase/items/calculate?job_no=${file.job_no}&revision=${file.revision || 'Rev.0'}&file_id=${file.id}`);
|
||||
|
||||
console.log('구매 수량 계산 응답:', response.data);
|
||||
const purchaseData = response.data;
|
||||
|
||||
if (purchaseData.success && purchaseData.items) {
|
||||
// 구매 수량 계산 결과를 모달로 표시
|
||||
setPurchaseModal({
|
||||
open: true,
|
||||
data: purchaseData.items,
|
||||
fileInfo: file
|
||||
});
|
||||
} else {
|
||||
alert('구매 수량 계산에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('구매 수량 계산 오류:', error);
|
||||
alert('구매 수량 계산 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 리비전 업로드 다이얼로그 열기
|
||||
const openRevisionDialog = (bomName, parentId) => {
|
||||
setRevisionDialog({ open: true, bomName, parentId });
|
||||
};
|
||||
|
||||
// 리비전 업로드
|
||||
const handleRevisionUpload = async () => {
|
||||
if (!revisionFile || !revisionDialog.bomName) {
|
||||
setError('파일을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', revisionFile);
|
||||
formData.append('job_no', jobNo);
|
||||
formData.append('bom_name', revisionDialog.bomName);
|
||||
formData.append('parent_id', revisionDialog.parentId);
|
||||
|
||||
await uploadFileApi(formData);
|
||||
|
||||
// 업로드 성공 후 파일 목록 새로고침
|
||||
await fetchFiles();
|
||||
|
||||
// 다이얼로그 닫기
|
||||
setRevisionDialog({ open: false, bomName: '', parentId: null });
|
||||
setRevisionFile(null);
|
||||
|
||||
} catch (err) {
|
||||
console.error('리비전 업로드 실패:', err);
|
||||
setError('리비전 업로드에 실패했습니다.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// BOM별로 그룹화
|
||||
const groupFilesByBOM = () => {
|
||||
const grouped = {};
|
||||
files.forEach(file => {
|
||||
const bomKey = file.bom_name || file.original_filename || file.filename;
|
||||
if (!grouped[bomKey]) {
|
||||
grouped[bomKey] = [];
|
||||
}
|
||||
grouped[bomKey].push(file);
|
||||
});
|
||||
|
||||
// 각 그룹을 리비전 순으로 정렬
|
||||
Object.keys(grouped).forEach(key => {
|
||||
grouped[key].sort((a, b) => {
|
||||
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
|
||||
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
|
||||
return revB - revA; // 최신 리비전이 먼저 오도록
|
||||
});
|
||||
});
|
||||
|
||||
return grouped;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
@@ -240,7 +111,11 @@ const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
|
||||
{/* 헤더 */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<button
|
||||
onClick={() => onNavigate && onNavigate('bom')}
|
||||
onClick={() => {
|
||||
if (onNavigate) {
|
||||
onNavigate('dashboard');
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'white',
|
||||
@@ -250,7 +125,7 @@ const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
← 뒤로가기
|
||||
← 메인으로 돌아가기
|
||||
</button>
|
||||
|
||||
<h1 style={{
|
||||
@@ -295,205 +170,126 @@ const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
|
||||
업로드된 BOM 목록
|
||||
</h3>
|
||||
|
||||
{/* 파일 테이블 컴포넌트 */}
|
||||
<BOMFileTable
|
||||
files={files}
|
||||
loading={loading}
|
||||
groupFilesByBOM={groupFilesByBOM}
|
||||
handleViewMaterials={handleViewMaterials}
|
||||
openRevisionDialog={openRevisionDialog}
|
||||
handleDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{/* 리비전 업로드 다이얼로그 */}
|
||||
<RevisionUploadDialog
|
||||
revisionDialog={revisionDialog}
|
||||
setRevisionDialog={setRevisionDialog}
|
||||
revisionFile={revisionFile}
|
||||
setRevisionFile={setRevisionFile}
|
||||
handleRevisionUpload={handleRevisionUpload}
|
||||
uploading={uploading}
|
||||
/>
|
||||
|
||||
{/* 구매 수량 계산 결과 모달 */}
|
||||
{purchaseModal.open && (
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
로딩 중...
|
||||
</div>
|
||||
) : (
|
||||
<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
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.07)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
maxWidth: '1000px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
margin: '20px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: 0
|
||||
}}>
|
||||
🧮 구매 수량 계산 결과
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setPurchaseModal({ open: false, data: null, fileInfo: null })}
|
||||
style={{
|
||||
background: '#e2e8f0',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
✕ 닫기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px', color: '#4a5568' }}>
|
||||
<div><strong>프로젝트:</strong> {purchaseModal.fileInfo?.job_no}</div>
|
||||
<div><strong>BOM:</strong> {purchaseModal.fileInfo?.bom_name}</div>
|
||||
<div><strong>리비전:</strong> {purchaseModal.fileInfo?.revision || 'Rev.0'}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f7fafc' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>카테고리</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사양</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사이즈</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>재질</th>
|
||||
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>BOM 수량</th>
|
||||
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>구매 수량</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>단위</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{purchaseModal.data?.map((item, index) => (
|
||||
<tr key={index} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
<span style={{
|
||||
background: getCategoryColor(item.category),
|
||||
<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',
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{item.category}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{item.specification}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{/* PIPE는 사양에 모든 정보가 포함되므로 사이즈 컬럼 비움 */}
|
||||
{item.category !== 'PIPE' && (
|
||||
<span style={{
|
||||
background: '#e6fffa',
|
||||
color: '#065f46',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{item.size_spec || '-'}
|
||||
</span>
|
||||
)}
|
||||
{item.category === 'PIPE' && (
|
||||
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||
사양에 포함
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{/* PIPE는 사양에 모든 정보가 포함되므로 재질 컬럼 비움 */}
|
||||
{item.category !== 'PIPE' && (
|
||||
<span style={{
|
||||
background: '#fef7e0',
|
||||
color: '#92400e',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{item.material_spec || '-'}
|
||||
</span>
|
||||
)}
|
||||
{item.category === 'PIPE' && (
|
||||
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||
사양에 포함
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right' }}>
|
||||
{item.category === 'PIPE' ?
|
||||
`${Math.round(item.bom_quantity)}mm` :
|
||||
item.bom_quantity
|
||||
}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right', fontWeight: '600' }}>
|
||||
{item.category === 'PIPE' ?
|
||||
`${item.pipes_count}본 (${Math.round(item.calculated_qty)}mm)` :
|
||||
item.calculated_qty
|
||||
}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{item.unit}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '12px', color: '#718096' }}>
|
||||
{item.category === 'PIPE' && (
|
||||
<div>
|
||||
<div>절단수: {item.cutting_count}회</div>
|
||||
<div>절단손실: {item.cutting_loss}mm</div>
|
||||
<div>활용률: {Math.round(item.utilization_rate)}%</div>
|
||||
</div>
|
||||
)}
|
||||
{item.category !== 'PIPE' && item.safety_factor && (
|
||||
<div>여유율: {Math.round((item.safety_factor - 1) * 100)}%</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '24px',
|
||||
padding: '16px',
|
||||
background: '#f7fafc',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#4a5568'
|
||||
}}
|
||||
>
|
||||
📋 자재 보기
|
||||
</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'
|
||||
}}>
|
||||
<div style={{ fontWeight: '600', marginBottom: '8px' }}>📋 계산 규칙 (올바른 규칙):</div>
|
||||
<div>• <strong>PIPE:</strong> 6M 단위 올림, 절단당 2mm 손실</div>
|
||||
<div>• <strong>FITTING:</strong> BOM 수량 그대로</div>
|
||||
<div>• <strong>VALVE:</strong> BOM 수량 그대로</div>
|
||||
<div>• <strong>BOLT:</strong> 5% 여유율 후 4의 배수 올림</div>
|
||||
<div>• <strong>GASKET:</strong> 5의 배수 올림</div>
|
||||
<div>• <strong>INSTRUMENT:</strong> BOM 수량 그대로</div>
|
||||
업로드된 BOM 파일이 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
720
frontend/src/pages/BOMWorkspacePage.jsx
Normal file
720
frontend/src/pages/BOMWorkspacePage.jsx
Normal file
@@ -0,0 +1,720 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { api, fetchFiles, deleteFile as deleteFileApi } from '../api';
|
||||
|
||||
const BOMWorkspacePage = ({ project, onNavigate, onBack }) => {
|
||||
// 상태 관리
|
||||
const [files, setFiles] = useState([]);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [sidebarWidth, setSidebarWidth] = useState(300);
|
||||
const [previewWidth, setPreviewWidth] = useState(400);
|
||||
|
||||
// 업로드 관련 상태
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
// 편집 상태
|
||||
const [editingFile, setEditingFile] = useState(null);
|
||||
const [editingField, setEditingField] = useState(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
console.log('🔄 프로젝트 변경됨:', project);
|
||||
const jobNo = project?.official_project_code || project?.job_no;
|
||||
if (jobNo) {
|
||||
console.log('✅ 프로젝트 코드 확인:', jobNo);
|
||||
// 프로젝트가 변경되면 기존 선택 초기화
|
||||
setSelectedFile(null);
|
||||
setFiles([]);
|
||||
loadFiles();
|
||||
} else {
|
||||
console.warn('⚠️ 프로젝트 정보가 없습니다. 받은 프로젝트:', project);
|
||||
setFiles([]);
|
||||
setSelectedFile(null);
|
||||
}
|
||||
}, [project?.official_project_code, project?.job_no]); // 두 필드 모두 감시
|
||||
|
||||
const loadFiles = async () => {
|
||||
const jobNo = project?.official_project_code || project?.job_no;
|
||||
if (!jobNo) {
|
||||
console.warn('프로젝트 정보가 없어서 파일을 로드할 수 없습니다:', project);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(''); // 에러 초기화
|
||||
|
||||
console.log('📂 파일 목록 로딩 시작:', jobNo);
|
||||
|
||||
const response = await api.get('/files/', {
|
||||
params: { job_no: jobNo }
|
||||
});
|
||||
|
||||
console.log('📂 API 응답:', response.data);
|
||||
|
||||
const fileList = Array.isArray(response.data) ? response.data : response.data?.files || [];
|
||||
console.log('📂 파싱된 파일 목록:', fileList);
|
||||
|
||||
setFiles(fileList);
|
||||
|
||||
// 기존 선택된 파일이 목록에 있는지 확인
|
||||
if (selectedFile && !fileList.find(f => f.id === selectedFile.id)) {
|
||||
setSelectedFile(null);
|
||||
}
|
||||
|
||||
// 첫 번째 파일 자동 선택 (기존 선택이 없을 때만)
|
||||
if (fileList.length > 0 && !selectedFile) {
|
||||
console.log('📂 첫 번째 파일 자동 선택:', fileList[0].original_filename);
|
||||
setSelectedFile(fileList[0]);
|
||||
}
|
||||
|
||||
console.log('📂 파일 로딩 완료:', fileList.length, '개 파일');
|
||||
} catch (err) {
|
||||
console.error('📂 파일 로딩 실패:', err);
|
||||
console.error('📂 에러 상세:', err.response?.data);
|
||||
setError(`파일 목록을 불러오는데 실패했습니다: ${err.response?.data?.detail || err.message}`);
|
||||
setFiles([]); // 에러 시 빈 배열로 초기화
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 드래그 앤 드롭 핸들러
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = async (e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
console.log('드롭된 파일들:', droppedFiles.map(f => ({ name: f.name, type: f.type })));
|
||||
|
||||
const excelFiles = droppedFiles.filter(file => {
|
||||
const fileName = file.name.toLowerCase();
|
||||
const isExcel = fileName.endsWith('.xlsx') || fileName.endsWith('.xls');
|
||||
console.log(`파일 ${file.name}: Excel 여부 = ${isExcel}`);
|
||||
return isExcel;
|
||||
});
|
||||
|
||||
if (excelFiles.length > 0) {
|
||||
console.log('업로드할 Excel 파일들:', excelFiles.map(f => f.name));
|
||||
await uploadFiles(excelFiles);
|
||||
} else {
|
||||
console.log('Excel 파일이 없음. 드롭된 파일들:', droppedFiles.map(f => f.name));
|
||||
alert(`Excel 파일만 업로드 가능합니다.\n업로드하려는 파일: ${droppedFiles.map(f => f.name).join(', ')}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const selectedFiles = Array.from(e.target.files);
|
||||
console.log('선택된 파일들:', selectedFiles.map(f => ({ name: f.name, type: f.type })));
|
||||
|
||||
const excelFiles = selectedFiles.filter(file => {
|
||||
const fileName = file.name.toLowerCase();
|
||||
const isExcel = fileName.endsWith('.xlsx') || fileName.endsWith('.xls');
|
||||
console.log(`파일 ${file.name}: Excel 여부 = ${isExcel}`);
|
||||
return isExcel;
|
||||
});
|
||||
|
||||
if (excelFiles.length > 0) {
|
||||
console.log('업로드할 Excel 파일들:', excelFiles.map(f => f.name));
|
||||
uploadFiles(excelFiles);
|
||||
} else {
|
||||
console.log('Excel 파일이 없음. 선택된 파일들:', selectedFiles.map(f => f.name));
|
||||
alert(`Excel 파일만 업로드 가능합니다.\n선택하려는 파일: ${selectedFiles.map(f => f.name).join(', ')}`);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFiles = async (filesToUpload) => {
|
||||
console.log('업로드 시작:', filesToUpload.map(f => ({ name: f.name, size: f.size, type: f.type })));
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
for (const file of filesToUpload) {
|
||||
console.log(`업로드 중: ${file.name} (${file.size} bytes, ${file.type})`);
|
||||
|
||||
const jobNo = project?.official_project_code || project?.job_no;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('job_no', jobNo);
|
||||
formData.append('bom_name', file.name.replace(/\.[^/.]+$/, "")); // 확장자 제거
|
||||
|
||||
console.log('FormData 내용:', {
|
||||
fileName: file.name,
|
||||
jobNo: jobNo,
|
||||
bomName: file.name.replace(/\.[^/.]+$/, "")
|
||||
});
|
||||
|
||||
const response = await api.post('/files/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
|
||||
console.log(`업로드 성공: ${file.name}`, response.data);
|
||||
}
|
||||
|
||||
await loadFiles(); // 목록 새로고침
|
||||
alert(`${filesToUpload.length}개 파일이 업로드되었습니다.`);
|
||||
} catch (err) {
|
||||
console.error('업로드 실패:', err);
|
||||
console.error('에러 상세:', err.response?.data);
|
||||
setError(`파일 업로드에 실패했습니다: ${err.response?.data?.detail || err.message}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 인라인 편집 핸들러
|
||||
const startEdit = (file, field) => {
|
||||
setEditingFile(file.id);
|
||||
setEditingField(field);
|
||||
setEditValue(file[field] || '');
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
try {
|
||||
await api.put(`/files/${editingFile}`, {
|
||||
[editingField]: editValue
|
||||
});
|
||||
|
||||
// 로컬 상태 업데이트
|
||||
setFiles(files.map(f =>
|
||||
f.id === editingFile
|
||||
? { ...f, [editingField]: editValue }
|
||||
: f
|
||||
));
|
||||
|
||||
if (selectedFile?.id === editingFile) {
|
||||
setSelectedFile({ ...selectedFile, [editingField]: editValue });
|
||||
}
|
||||
|
||||
cancelEdit();
|
||||
} catch (err) {
|
||||
console.error('수정 실패:', err);
|
||||
alert('수정에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingFile(null);
|
||||
setEditingField(null);
|
||||
setEditValue('');
|
||||
};
|
||||
|
||||
// 파일 삭제
|
||||
const handleDelete = async (fileId) => {
|
||||
if (!window.confirm('정말로 이 파일을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteFileApi(fileId);
|
||||
setFiles(files.filter(f => f.id !== fileId));
|
||||
|
||||
if (selectedFile?.id === fileId) {
|
||||
const remainingFiles = files.filter(f => f.id !== fileId);
|
||||
setSelectedFile(remainingFiles.length > 0 ? remainingFiles[0] : null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('삭제 실패:', err);
|
||||
setError('파일 삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 자재 보기
|
||||
const viewMaterials = (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={{
|
||||
display: 'flex',
|
||||
height: '100vh',
|
||||
background: '#f5f5f5',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
}}>
|
||||
{/* 사이드바 - 프로젝트 정보 */}
|
||||
<div style={{
|
||||
width: `${sidebarWidth}px`,
|
||||
background: '#ffffff',
|
||||
borderRight: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
background: '#fafafa'
|
||||
}}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
marginBottom: '8px'
|
||||
}}
|
||||
>
|
||||
← 대시보드로
|
||||
</button>
|
||||
<h2 style={{
|
||||
margin: 0,
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#333'
|
||||
}}>
|
||||
{project?.project_name}
|
||||
</h2>
|
||||
<p style={{
|
||||
margin: '4px 0 0 0',
|
||||
fontSize: '14px',
|
||||
color: '#666'
|
||||
}}>
|
||||
{project?.official_project_code || project?.job_no}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 통계 */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '8px' }}>
|
||||
프로젝트 현황
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '8px', background: '#f8f9fa', borderRadius: '4px' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '600', color: '#4299e1' }}>
|
||||
{files.length}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>BOM 파일</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '8px', background: '#f8f9fa', borderRadius: '4px' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '600', color: '#48bb78' }}>
|
||||
{files.reduce((sum, f) => sum + (f.parsed_count || 0), 0)}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>총 자재</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 업로드 영역 */}
|
||||
<div
|
||||
style={{
|
||||
margin: '16px',
|
||||
padding: '20px',
|
||||
border: dragOver ? '2px dashed #4299e1' : '2px dashed #ddd',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center',
|
||||
background: dragOver ? '#f0f9ff' : '#fafafa',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{uploading ? (
|
||||
<div style={{ color: '#4299e1' }}>
|
||||
📤 업로드 중...
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ fontSize: '24px', marginBottom: '8px' }}>📁</div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
Excel 파일을 드래그하거나<br />클릭하여 업로드
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 패널 - 파일 목록 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#ffffff'
|
||||
}}>
|
||||
{/* 툴바 */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
background: '#fafafa',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: '600' }}>
|
||||
BOM 파일 목록 ({files.length})
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={loadFiles}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: loading ? '#a0aec0' : '#48bb78',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
{loading ? '🔄 로딩중...' : '🔄 새로고침'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#4299e1',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
+ 파일 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 파일 목록 */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#666' }}>
|
||||
로딩 중...
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#666' }}>
|
||||
업로드된 BOM 파일이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
cursor: 'pointer',
|
||||
background: selectedFile?.id === file.id ? '#f0f9ff' : 'transparent',
|
||||
transition: 'background-color 0.2s ease'
|
||||
}}
|
||||
onClick={() => setSelectedFile(file)}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedFile?.id !== file.id) {
|
||||
e.target.style.background = '#f8f9fa';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedFile?.id !== file.id) {
|
||||
e.target.style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{/* BOM 이름 (인라인 편집) */}
|
||||
{editingFile === file.id && editingField === 'bom_name' ? (
|
||||
<input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={saveEdit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') saveEdit();
|
||||
if (e.key === 'Escape') cancelEdit();
|
||||
}}
|
||||
style={{
|
||||
border: '1px solid #4299e1',
|
||||
borderRadius: '2px',
|
||||
padding: '2px 4px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
cursor: 'text'
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEdit(file, 'bom_name');
|
||||
}}
|
||||
>
|
||||
{file.bom_name || file.original_filename}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '2px' }}>
|
||||
{file.original_filename} • {file.parsed_count || 0}개 자재 • {file.revision || 'Rev.0'}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#999', marginTop: '2px' }}>
|
||||
{new Date(file.created_at).toLocaleDateString('ko-KR')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '4px', marginLeft: '12px' }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
viewMaterials(file);
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: '#48bb78',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px'
|
||||
}}
|
||||
>
|
||||
📋 자재
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
alert('리비전 기능 준비 중');
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: 'white',
|
||||
color: '#4299e1',
|
||||
border: '1px solid #4299e1',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px'
|
||||
}}
|
||||
>
|
||||
📝 리비전
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(file.id);
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: '#f56565',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px'
|
||||
}}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 패널 - 상세 정보 */}
|
||||
{selectedFile && (
|
||||
<div style={{
|
||||
width: `${previewWidth}px`,
|
||||
background: '#ffffff',
|
||||
borderLeft: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
{/* 상세 정보 헤더 */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
background: '#fafafa'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: '600' }}>
|
||||
파일 상세 정보
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 내용 */}
|
||||
<div style={{ padding: '16px', flex: 1, overflow: 'auto' }}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
|
||||
BOM 이름
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '4px',
|
||||
cursor: 'text',
|
||||
background: '#fafafa'
|
||||
}}
|
||||
onClick={() => startEdit(selectedFile, 'bom_name')}
|
||||
>
|
||||
{selectedFile.bom_name || selectedFile.original_filename}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
|
||||
파일명
|
||||
</label>
|
||||
<div style={{ fontSize: '14px', color: '#333' }}>
|
||||
{selectedFile.original_filename}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
|
||||
리비전
|
||||
</label>
|
||||
<div style={{ fontSize: '14px', color: '#333' }}>
|
||||
{selectedFile.revision || 'Rev.0'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
|
||||
자재 수
|
||||
</label>
|
||||
<div style={{ fontSize: '14px', color: '#333' }}>
|
||||
{selectedFile.parsed_count || 0}개
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
|
||||
업로드 일시
|
||||
</label>
|
||||
<div style={{ fontSize: '14px', color: '#333' }}>
|
||||
{new Date(selectedFile.created_at).toLocaleString('ko-KR')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<div style={{ marginTop: '24px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => viewMaterials(selectedFile)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
background: '#4299e1',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
📋 자재 목록 보기
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => alert('리비전 업로드 기능 준비 중')}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
background: 'white',
|
||||
color: '#4299e1',
|
||||
border: '1px solid #4299e1',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
📝 새 리비전 업로드
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(selectedFile.id)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
background: '#f56565',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
🗑️ 파일 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
background: '#fed7d7',
|
||||
color: '#c53030',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #fc8181',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
{error}
|
||||
<button
|
||||
onClick={() => setError('')}
|
||||
style={{
|
||||
marginLeft: '12px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#c53030',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMWorkspacePage;
|
||||
@@ -262,3 +262,19 @@ const DashboardPage = ({ user }) => {
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -217,3 +217,19 @@
|
||||
border-color: #667eea;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -114,3 +114,19 @@ const LoginPage = () => {
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
464
frontend/src/pages/NewMaterialsPage.css
Normal file
464
frontend/src/pages/NewMaterialsPage.css
Normal file
@@ -0,0 +1,464 @@
|
||||
/* NewMaterialsPage - DevonThink 스타일 */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.materials-page {
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.materials-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: #5558e3;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.materials-header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.job-info {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.material-count {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
background: #f3f4f6;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 카테고리 필터 */
|
||||
.category-filters {
|
||||
background: white;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.category-filters::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.category-filters::-webkit-scrollbar-track {
|
||||
background: #f3f4f6;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.category-filters::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.category-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.category-btn:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.category-btn.active {
|
||||
background: #eef2ff;
|
||||
border-color: #6366f1;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.category-btn .count {
|
||||
background: #f3f4f6;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.category-btn.active .count {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 액션 바 */
|
||||
.action-bar {
|
||||
background: white;
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.selection-info {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.select-all-btn,
|
||||
.export-btn {
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.select-all-btn {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.select-all-btn:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.export-btn:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.export-btn:disabled {
|
||||
background: #e5e7eb;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 자재 테이블 */
|
||||
.materials-grid {
|
||||
background: white;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detailed-grid-header {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 80px 120px 80px 80px 140px 100px 150px 100px;
|
||||
padding: 12px 24px;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* 플랜지 전용 헤더 - 10개 컬럼 */
|
||||
.detailed-grid-header.flange-header {
|
||||
grid-template-columns: 40px 80px 80px 80px 100px 80px 140px 100px 150px 100px;
|
||||
}
|
||||
|
||||
/* 플랜지 전용 행 - 10개 컬럼 */
|
||||
.detailed-material-row.flange-row {
|
||||
grid-template-columns: 40px 80px 80px 80px 100px 80px 140px 100px 150px 100px;
|
||||
}
|
||||
|
||||
/* 피팅 전용 헤더 - 10개 컬럼 */
|
||||
.detailed-grid-header.fitting-header {
|
||||
grid-template-columns: 40px 80px 150px 80px 80px 80px 140px 100px 150px 100px;
|
||||
}
|
||||
|
||||
/* 피팅 전용 행 - 10개 컬럼 */
|
||||
.detailed-material-row.fitting-row {
|
||||
grid-template-columns: 40px 80px 150px 80px 80px 80px 140px 100px 150px 100px;
|
||||
}
|
||||
|
||||
/* 밸브 전용 헤더 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */
|
||||
.detailed-grid-header.valve-header {
|
||||
grid-template-columns: 40px 120px 100px 80px 80px 140px 100px 150px 100px;
|
||||
}
|
||||
|
||||
/* 밸브 전용 행 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */
|
||||
.detailed-material-row.valve-row {
|
||||
grid-template-columns: 40px 120px 100px 80px 80px 140px 100px 150px 100px;
|
||||
}
|
||||
|
||||
/* 가스켓 전용 헤더 - 11개 컬럼 (타입 좁게, 상세내역 넓게, 두께 추가) */
|
||||
.detailed-grid-header.gasket-header {
|
||||
grid-template-columns: 40px 80px 60px 80px 80px 100px 180px 60px 80px 150px 100px;
|
||||
}
|
||||
|
||||
/* 가스켓 전용 행 - 11개 컬럼 (타입 좁게, 상세내역 넓게, 두께 추가) */
|
||||
.detailed-material-row.gasket-row {
|
||||
grid-template-columns: 40px 80px 60px 80px 80px 100px 180px 60px 80px 150px 100px;
|
||||
}
|
||||
|
||||
/* UNKNOWN 전용 헤더 - 5개 컬럼 */
|
||||
.detailed-grid-header.unknown-header {
|
||||
grid-template-columns: 40px 100px 1fr 150px 100px;
|
||||
}
|
||||
|
||||
/* UNKNOWN 전용 행 - 5개 컬럼 */
|
||||
.detailed-material-row.unknown-row {
|
||||
grid-template-columns: 40px 100px 1fr 150px 100px;
|
||||
}
|
||||
|
||||
/* UNKNOWN 설명 셀 스타일 */
|
||||
.description-cell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.description-text {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detailed-material-row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 80px 120px 80px 80px 140px 100px 150px 100px;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
align-items: center;
|
||||
transition: background 0.15s;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detailed-material-row:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.detailed-material-row.selected {
|
||||
background: #f0f9ff;
|
||||
}
|
||||
|
||||
.material-cell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.material-cell input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 타입 배지 */
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.type-badge.pipe {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.type-badge.fitting {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.type-badge.valve {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.type-badge.flange {
|
||||
background: #8b5cf6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.type-badge.bolt {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.type-badge.gasket {
|
||||
background: #06b6d4;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.type-badge.unknown {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.type-badge.instrument {
|
||||
background: #78716c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.type-badge.unknown {
|
||||
background: #9ca3af;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 텍스트 스타일 */
|
||||
.subtype-text,
|
||||
.size-text,
|
||||
.material-grade {
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 입력 필드 */
|
||||
.user-req-input {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background: #fafbfc;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.user-req-input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.user-req-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 수량 정보 */
|
||||
.quantity-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* 플랜지 압력 정보 */
|
||||
.pressure-info {
|
||||
font-weight: 600;
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.quantity-value {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.quantity-details {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 로딩 */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #f3f4f6;
|
||||
border-top: 3px solid #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.loading-container p {
|
||||
margin-top: 16px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 스크롤바 스타일 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
971
frontend/src/pages/NewMaterialsPage.jsx
Normal file
971
frontend/src/pages/NewMaterialsPage.jsx
Normal file
@@ -0,0 +1,971 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { fetchMaterials } from '../api';
|
||||
import './NewMaterialsPage.css';
|
||||
|
||||
const NewMaterialsPage = ({
|
||||
onNavigate,
|
||||
selectedProject,
|
||||
fileId,
|
||||
jobNo,
|
||||
bomName,
|
||||
revision,
|
||||
filename
|
||||
}) => {
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedCategory, setSelectedCategory] = useState('PIPE');
|
||||
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
|
||||
const [viewMode, setViewMode] = useState('detailed'); // 'detailed' or 'simple'
|
||||
|
||||
// 자재 데이터 로드
|
||||
useEffect(() => {
|
||||
if (fileId) {
|
||||
loadMaterials(fileId);
|
||||
}
|
||||
}, [fileId]);
|
||||
|
||||
const loadMaterials = async (id) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
console.log('🔍 자재 데이터 로딩 중...', { file_id: id });
|
||||
|
||||
const response = await fetchMaterials({
|
||||
file_id: parseInt(id),
|
||||
limit: 10000
|
||||
});
|
||||
|
||||
if (response.data?.materials) {
|
||||
const materialsData = response.data.materials;
|
||||
console.log(`✅ ${materialsData.length}개 자재 로드 완료`);
|
||||
|
||||
// 파이프 데이터 검증
|
||||
const pipes = materialsData.filter(m => m.classified_category === 'PIPE');
|
||||
if (pipes.length > 0) {
|
||||
console.log('📊 파이프 데이터 샘플:', pipes[0]);
|
||||
}
|
||||
|
||||
setMaterials(materialsData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 자재 로딩 실패:', error);
|
||||
setMaterials([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리별 자재 수 계산
|
||||
const getCategoryCounts = () => {
|
||||
const counts = {};
|
||||
materials.forEach(material => {
|
||||
const category = material.classified_category || 'UNKNOWN';
|
||||
counts[category] = (counts[category] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
};
|
||||
|
||||
// 파이프 구매 수량 계산 함수
|
||||
const calculatePipePurchase = (material) => {
|
||||
// 백엔드에서 이미 그룹핑된 데이터 사용
|
||||
const totalLength = material.pipe_details?.total_length_mm || 0;
|
||||
const pipeCount = material.pipe_details?.pipe_count || material.quantity || 0;
|
||||
|
||||
// 절단 손실: 각 단관마다 2mm
|
||||
const cuttingLoss = pipeCount * 2;
|
||||
|
||||
// 총 필요 길이
|
||||
const requiredLength = totalLength + cuttingLoss;
|
||||
|
||||
// 6M(6000mm) 단위로 구매 본수 계산
|
||||
const purchaseCount = Math.ceil(requiredLength / 6000);
|
||||
|
||||
return {
|
||||
pipeCount, // 단관 개수
|
||||
totalLength, // 총 BOM 길이
|
||||
cuttingLoss, // 절단 손실
|
||||
requiredLength, // 필요 길이
|
||||
purchaseCount // 구매 본수
|
||||
};
|
||||
};
|
||||
|
||||
// 자재 정보 파싱
|
||||
const parseMaterialInfo = (material) => {
|
||||
const category = material.classified_category;
|
||||
|
||||
if (category === 'PIPE') {
|
||||
const calc = calculatePipePurchase(material);
|
||||
return {
|
||||
type: 'PIPE',
|
||||
subtype: material.pipe_details?.manufacturing_method || 'SMLS',
|
||||
size: material.size_spec || '-',
|
||||
schedule: material.pipe_details?.schedule || '-',
|
||||
grade: material.material_grade || '-',
|
||||
quantity: calc.purchaseCount,
|
||||
unit: '본',
|
||||
details: calc
|
||||
};
|
||||
} else if (category === 'FITTING') {
|
||||
const fittingDetails = material.fitting_details || {};
|
||||
const fittingType = fittingDetails.fitting_type || '';
|
||||
const fittingSubtype = fittingDetails.fitting_subtype || '';
|
||||
const description = material.original_description || '';
|
||||
|
||||
// 피팅 타입별 상세 표시
|
||||
let displayType = '';
|
||||
|
||||
// CAP과 PLUG 먼저 확인 (fitting_type이 없을 수 있음)
|
||||
if (description.toUpperCase().includes('CAP')) {
|
||||
// CAP: 연결 방식 표시 (예: CAP, NPT(F), 3000LB, ASTM A105)
|
||||
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')) {
|
||||
// PLUG: 타입과 연결 방식 표시 (예: HEX.PLUG, NPT(M), 6000LB, ASTM A105)
|
||||
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;
|
||||
displayType = length ? `NIPPLE ${length}mm` : 'NIPPLE';
|
||||
} else if (fittingType === 'ELBOW') {
|
||||
// 엘보: 각도와 연결 방식
|
||||
const angle = fittingSubtype === '90DEG' ? '90°' : fittingSubtype === '45DEG' ? '45°' : '';
|
||||
const connection = description.includes('SW') ? 'SW' : description.includes('BW') ? 'BW' : '';
|
||||
displayType = `ELBOW ${angle} ${connection}`.trim();
|
||||
} else if (fittingType === 'TEE') {
|
||||
// 티: 타입과 연결 방식
|
||||
const teeType = fittingSubtype === 'EQUAL' ? 'EQ' : fittingSubtype === 'REDUCING' ? 'RED' : '';
|
||||
const connection = description.includes('SW') ? 'SW' : description.includes('BW') ? 'BW' : '';
|
||||
displayType = `TEE ${teeType} ${connection}`.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 (!displayType) {
|
||||
// 기타 피팅 타입
|
||||
displayType = fittingType || 'FITTING';
|
||||
}
|
||||
|
||||
// 압력 등급과 스케줄 추출
|
||||
let pressure = '-';
|
||||
let schedule = '-';
|
||||
|
||||
// 압력 등급 찾기 (3000LB, 6000LB 등)
|
||||
const pressureMatch = description.match(/(\d+)LB/i);
|
||||
if (pressureMatch) {
|
||||
pressure = `${pressureMatch[1]}LB`;
|
||||
}
|
||||
|
||||
// 스케줄 찾기
|
||||
if (description.includes('SCH')) {
|
||||
const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i);
|
||||
if (schMatch) {
|
||||
schedule = `SCH ${schMatch[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'FITTING',
|
||||
subtype: displayType,
|
||||
size: material.size_spec || '-',
|
||||
pressure: pressure,
|
||||
schedule: schedule,
|
||||
grade: material.material_grade || '-',
|
||||
quantity: Math.round(material.quantity || 0),
|
||||
unit: '개',
|
||||
isFitting: true
|
||||
};
|
||||
} else if (category === 'VALVE') {
|
||||
const valveDetails = material.valve_details || {};
|
||||
const description = material.original_description || '';
|
||||
|
||||
// 밸브 타입 파싱 (GATE, BALL, CHECK, GLOBE 등)
|
||||
let valveType = valveDetails.valve_type || '';
|
||||
if (!valveType && description) {
|
||||
if (description.includes('GATE')) valveType = 'GATE';
|
||||
else if (description.includes('BALL')) valveType = 'BALL';
|
||||
else if (description.includes('CHECK')) valveType = 'CHECK';
|
||||
else if (description.includes('GLOBE')) valveType = 'GLOBE';
|
||||
else if (description.includes('BUTTERFLY')) valveType = 'BUTTERFLY';
|
||||
}
|
||||
|
||||
// 연결 방식 파싱 (FLG, SW, THRD 등)
|
||||
let connectionType = '';
|
||||
if (description.includes('FLG')) {
|
||||
connectionType = 'FLG';
|
||||
} else if (description.includes('SW X THRD')) {
|
||||
connectionType = 'SW×THRD';
|
||||
} else if (description.includes('SW')) {
|
||||
connectionType = 'SW';
|
||||
} else if (description.includes('THRD')) {
|
||||
connectionType = 'THRD';
|
||||
} else if (description.includes('BW')) {
|
||||
connectionType = 'BW';
|
||||
}
|
||||
|
||||
// 압력 등급 파싱
|
||||
let pressure = '-';
|
||||
const pressureMatch = description.match(/(\d+)LB/i);
|
||||
if (pressureMatch) {
|
||||
pressure = `${pressureMatch[1]}LB`;
|
||||
}
|
||||
|
||||
// 스케줄은 밸브에는 일반적으로 없음
|
||||
let schedule = '-';
|
||||
|
||||
return {
|
||||
type: 'VALVE',
|
||||
valveType: valveType,
|
||||
connectionType: connectionType,
|
||||
size: material.size_spec || '-',
|
||||
pressure: pressure,
|
||||
schedule: schedule,
|
||||
grade: material.material_grade || '-',
|
||||
quantity: Math.round(material.quantity || 0),
|
||||
unit: '개',
|
||||
isValve: true
|
||||
};
|
||||
} else if (category === 'FLANGE') {
|
||||
// 플랜지 타입 변환
|
||||
const flangeTypeMap = {
|
||||
'WELD_NECK': 'WN',
|
||||
'SLIP_ON': 'SO',
|
||||
'BLIND': 'BL',
|
||||
'SOCKET_WELD': 'SW',
|
||||
'LAP_JOINT': 'LJ',
|
||||
'THREADED': 'TH',
|
||||
'ORIFICE': 'ORIFICE' // 오리피스는 풀네임 표시
|
||||
};
|
||||
const flangeType = material.flange_details?.flange_type;
|
||||
const displayType = flangeTypeMap[flangeType] || flangeType || '-';
|
||||
|
||||
// 원본 설명에서 스케줄 추출
|
||||
let schedule = '-';
|
||||
const description = material.original_description || '';
|
||||
|
||||
// SCH 40, SCH 80 등의 패턴 찾기
|
||||
if (description.toUpperCase().includes('SCH')) {
|
||||
const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i);
|
||||
if (schMatch && schMatch[1]) {
|
||||
schedule = `SCH ${schMatch[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'FLANGE',
|
||||
subtype: displayType,
|
||||
size: material.size_spec || '-',
|
||||
pressure: material.flange_details?.pressure_rating || '-',
|
||||
schedule: schedule,
|
||||
grade: material.material_grade || '-',
|
||||
quantity: Math.round(material.quantity || 0),
|
||||
unit: '개',
|
||||
isFlange: true // 플랜지 구분용 플래그
|
||||
};
|
||||
} else if (category === 'BOLT') {
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
const safetyQty = Math.ceil(qty * 1.05); // 5% 여유율
|
||||
const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수
|
||||
return {
|
||||
type: 'BOLT',
|
||||
subtype: material.bolt_details?.bolt_type || '-',
|
||||
size: material.size_spec || '-',
|
||||
schedule: material.bolt_details?.length || '-',
|
||||
grade: material.material_grade || '-',
|
||||
quantity: purchaseQty,
|
||||
unit: 'SETS'
|
||||
};
|
||||
} else if (category === 'GASKET') {
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
const purchaseQty = Math.ceil(qty / 5) * 5; // 5의 배수
|
||||
|
||||
// original_description에서 재질 정보 파싱
|
||||
const description = material.original_description || '';
|
||||
let materialStructure = '-'; // H/F/I/O 부분
|
||||
let materialDetail = '-'; // SS304/GRAPHITE/CS/CS 부분
|
||||
|
||||
// H/F/I/O와 재질 상세 정보 추출
|
||||
const materialMatch = description.match(/H\/F\/I\/O\s+(.+?)(?:,|$)/);
|
||||
if (materialMatch) {
|
||||
materialStructure = 'H/F/I/O';
|
||||
materialDetail = materialMatch[1].trim();
|
||||
// 두께 정보 제거 (별도 추출)
|
||||
materialDetail = materialDetail.replace(/,?\s*\d+(?:\.\d+)?mm$/, '').trim();
|
||||
}
|
||||
|
||||
// 압력 정보 추출
|
||||
let pressure = '-';
|
||||
const pressureMatch = description.match(/(\d+LB)/);
|
||||
if (pressureMatch) {
|
||||
pressure = pressureMatch[1];
|
||||
}
|
||||
|
||||
// 두께 정보 추출
|
||||
let thickness = '-';
|
||||
const thicknessMatch = description.match(/(\d+(?:\.\d+)?)\s*mm/i);
|
||||
if (thicknessMatch) {
|
||||
thickness = thicknessMatch[1] + 'mm';
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'GASKET',
|
||||
subtype: 'SWG', // 항상 SWG로 표시
|
||||
size: material.size_spec || '-',
|
||||
pressure: pressure,
|
||||
materialStructure: materialStructure,
|
||||
materialDetail: materialDetail,
|
||||
thickness: thickness,
|
||||
quantity: purchaseQty,
|
||||
unit: '개',
|
||||
isGasket: true
|
||||
};
|
||||
} else if (category === 'UNKNOWN') {
|
||||
return {
|
||||
type: 'UNKNOWN',
|
||||
description: material.original_description || 'Unknown Item',
|
||||
quantity: Math.round(material.quantity || 0),
|
||||
unit: '개',
|
||||
isUnknown: true
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: category || 'UNKNOWN',
|
||||
subtype: '-',
|
||||
size: material.size_spec || '-',
|
||||
schedule: '-',
|
||||
grade: material.material_grade || '-',
|
||||
quantity: Math.round(material.quantity || 0),
|
||||
unit: '개'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 필터링된 자재 목록
|
||||
const filteredMaterials = materials.filter(material => {
|
||||
return material.classified_category === selectedCategory;
|
||||
});
|
||||
|
||||
// 카테고리 색상 (제거 - CSS에서 처리)
|
||||
|
||||
// 전체 선택/해제
|
||||
const toggleAllSelection = () => {
|
||||
if (selectedMaterials.size === filteredMaterials.length) {
|
||||
setSelectedMaterials(new Set());
|
||||
} else {
|
||||
setSelectedMaterials(new Set(filteredMaterials.map(m => m.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 선택
|
||||
const toggleMaterialSelection = (id) => {
|
||||
const newSelection = new Set(selectedMaterials);
|
||||
if (newSelection.has(id)) {
|
||||
newSelection.delete(id);
|
||||
} else {
|
||||
newSelection.add(id);
|
||||
}
|
||||
setSelectedMaterials(newSelection);
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const exportToExcel = () => {
|
||||
const selectedData = materials.filter(m => selectedMaterials.has(m.id));
|
||||
console.log('📊 엑셀 내보내기:', selectedData.length, '개 항목');
|
||||
alert(`${selectedData.length}개 항목을 엑셀로 내보냅니다.`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>자재 목록을 불러오는 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const categoryCounts = getCategoryCounts();
|
||||
|
||||
return (
|
||||
<div className="materials-page">
|
||||
{/* 헤더 */}
|
||||
<div className="materials-header">
|
||||
<div className="header-left">
|
||||
<button onClick={() => onNavigate('bom')} className="back-button">
|
||||
← BOM 업로드로 돌아가기
|
||||
</button>
|
||||
<h1>자재 목록</h1>
|
||||
{jobNo && (
|
||||
<span className="job-info">
|
||||
{jobNo} {revision && `(${revision})`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<span className="material-count">
|
||||
총 {materials.length}개 자재
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<div className="category-filters">
|
||||
{Object.entries(categoryCounts).map(([category, count]) => (
|
||||
<button
|
||||
key={category}
|
||||
className={`category-btn ${selectedCategory === category ? 'active' : ''}`}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
>
|
||||
{category} <span className="count">{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 액션 바 */}
|
||||
<div className="action-bar">
|
||||
<div className="selection-info">
|
||||
{selectedMaterials.size}개 중 {filteredMaterials.length}개 선택
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<button
|
||||
onClick={toggleAllSelection}
|
||||
className="select-all-btn"
|
||||
>
|
||||
{selectedMaterials.size === filteredMaterials.length ? '전체 해제' : '전체 선택'}
|
||||
</button>
|
||||
<button
|
||||
onClick={exportToExcel}
|
||||
className="export-btn"
|
||||
disabled={selectedMaterials.size === 0}
|
||||
>
|
||||
엑셀 내보내기 ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자재 테이블 */}
|
||||
<div className="materials-grid">
|
||||
{/* 플랜지 전용 헤더 */}
|
||||
{selectedCategory === 'FLANGE' ? (
|
||||
<div className="detailed-grid-header flange-header">
|
||||
<div>선택</div>
|
||||
<div>종류</div>
|
||||
<div>타입</div>
|
||||
<div>크기</div>
|
||||
<div>압력(파운드)</div>
|
||||
<div>스케줄</div>
|
||||
<div>재질</div>
|
||||
<div>추가요구</div>
|
||||
<div>사용자요구</div>
|
||||
<div>수량</div>
|
||||
</div>
|
||||
) : selectedCategory === 'FITTING' ? (
|
||||
<div className="detailed-grid-header fitting-header">
|
||||
<div>선택</div>
|
||||
<div>종류</div>
|
||||
<div>타입/상세</div>
|
||||
<div>크기</div>
|
||||
<div>압력</div>
|
||||
<div>스케줄</div>
|
||||
<div>재질</div>
|
||||
<div>추가요구</div>
|
||||
<div>사용자요구</div>
|
||||
<div>수량</div>
|
||||
</div>
|
||||
) : selectedCategory === 'GASKET' ? (
|
||||
<div className="detailed-grid-header gasket-header">
|
||||
<div>선택</div>
|
||||
<div>종류</div>
|
||||
<div>타입</div>
|
||||
<div>크기</div>
|
||||
<div>압력</div>
|
||||
<div>재질</div>
|
||||
<div>상세내역</div>
|
||||
<div>두께</div>
|
||||
<div>추가요구</div>
|
||||
<div>사용자요구</div>
|
||||
<div>수량</div>
|
||||
</div>
|
||||
) : selectedCategory === 'VALVE' ? (
|
||||
<div className="detailed-grid-header valve-header">
|
||||
<div>선택</div>
|
||||
<div>타입</div>
|
||||
<div>연결방식</div>
|
||||
<div>크기</div>
|
||||
<div>압력</div>
|
||||
<div>재질</div>
|
||||
<div>추가요구</div>
|
||||
<div>사용자요구</div>
|
||||
<div>수량</div>
|
||||
</div>
|
||||
) : selectedCategory === 'UNKNOWN' ? (
|
||||
<div className="detailed-grid-header unknown-header">
|
||||
<div>선택</div>
|
||||
<div>종류</div>
|
||||
<div>설명</div>
|
||||
<div>사용자요구</div>
|
||||
<div>수량</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="detailed-grid-header">
|
||||
<div>선택</div>
|
||||
<div>종류</div>
|
||||
<div>타입</div>
|
||||
<div>크기</div>
|
||||
<div>스케줄</div>
|
||||
<div>재질</div>
|
||||
<div>추가요구</div>
|
||||
<div>사용자요구</div>
|
||||
<div>수량</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredMaterials.map((material) => {
|
||||
const info = parseMaterialInfo(material);
|
||||
|
||||
// 피팅인 경우 10개 컬럼
|
||||
if (info.isFitting) {
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`detailed-material-row fitting-row ${selectedMaterials.has(material.id) ? 'selected' : ''}`}
|
||||
>
|
||||
{/* 선택 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onChange={() => toggleMaterialSelection(material.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 종류 */}
|
||||
<div className="material-cell">
|
||||
<span className={`type-badge ${info.type.toLowerCase()}`}>
|
||||
{info.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 타입/상세 */}
|
||||
<div className="material-cell">
|
||||
<span className="subtype-text">{info.subtype}</span>
|
||||
</div>
|
||||
|
||||
{/* 크기 */}
|
||||
<div className="material-cell">
|
||||
<span className="size-text">{info.size}</span>
|
||||
</div>
|
||||
|
||||
{/* 압력 */}
|
||||
<div className="material-cell">
|
||||
<span className="pressure-info">{info.pressure}</span>
|
||||
</div>
|
||||
|
||||
{/* 스케줄 */}
|
||||
<div className="material-cell">
|
||||
<span>{info.schedule}</span>
|
||||
</div>
|
||||
|
||||
{/* 재질 */}
|
||||
<div className="material-cell">
|
||||
<span className="material-grade">{info.grade}</span>
|
||||
</div>
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="text"
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수량 */}
|
||||
<div className="material-cell">
|
||||
<div className="quantity-info">
|
||||
<span className="quantity-value">
|
||||
{info.quantity} {info.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 밸브인 경우 10개 컬럼
|
||||
if (info.isValve) {
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`detailed-material-row valve-row ${selectedMaterials.has(material.id) ? 'selected' : ''}`}
|
||||
>
|
||||
{/* 선택 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onChange={() => toggleMaterialSelection(material.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 타입 */}
|
||||
<div className="material-cell">
|
||||
<span className="valve-type">{info.valveType}</span>
|
||||
</div>
|
||||
|
||||
{/* 연결방식 */}
|
||||
<div className="material-cell">
|
||||
<span className="connection-type">{info.connectionType}</span>
|
||||
</div>
|
||||
|
||||
{/* 크기 */}
|
||||
<div className="material-cell">
|
||||
<span className="size-text">{info.size}</span>
|
||||
</div>
|
||||
|
||||
{/* 압력 */}
|
||||
<div className="material-cell">
|
||||
<span className="pressure-info">{info.pressure}</span>
|
||||
</div>
|
||||
|
||||
{/* 재질 */}
|
||||
<div className="material-cell">
|
||||
<span className="material-grade">{info.grade}</span>
|
||||
</div>
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="text"
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수량 */}
|
||||
<div className="material-cell">
|
||||
<div className="quantity-info">
|
||||
<span className="quantity-value">
|
||||
{info.quantity} {info.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 플랜지인 경우 10개 컬럼
|
||||
if (info.isFlange) {
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`detailed-material-row flange-row ${selectedMaterials.has(material.id) ? 'selected' : ''}`}
|
||||
>
|
||||
{/* 선택 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onChange={() => toggleMaterialSelection(material.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 종류 */}
|
||||
<div className="material-cell">
|
||||
<span className={`type-badge ${info.type.toLowerCase()}`}>
|
||||
{info.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 타입 */}
|
||||
<div className="material-cell">
|
||||
<span className="subtype-text">{info.subtype}</span>
|
||||
</div>
|
||||
|
||||
{/* 크기 */}
|
||||
<div className="material-cell">
|
||||
<span className="size-text">{info.size}</span>
|
||||
</div>
|
||||
|
||||
{/* 압력(파운드) */}
|
||||
<div className="material-cell">
|
||||
<span className="pressure-info">{info.pressure}</span>
|
||||
</div>
|
||||
|
||||
{/* 스케줄 */}
|
||||
<div className="material-cell">
|
||||
<span>{info.schedule}</span>
|
||||
</div>
|
||||
|
||||
{/* 재질 */}
|
||||
<div className="material-cell">
|
||||
<span className="material-grade">{info.grade}</span>
|
||||
</div>
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="text"
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수량 */}
|
||||
<div className="material-cell">
|
||||
<div className="quantity-info">
|
||||
<span className="quantity-value">
|
||||
{info.quantity} {info.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// UNKNOWN인 경우 5개 컬럼
|
||||
if (info.isUnknown) {
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`detailed-material-row unknown-row ${selectedMaterials.has(material.id) ? 'selected' : ''}`}
|
||||
>
|
||||
{/* 선택 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onChange={() => toggleMaterialSelection(material.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 종류 */}
|
||||
<div className="material-cell">
|
||||
<span className={`type-badge unknown`}>
|
||||
{info.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="material-cell description-cell">
|
||||
<span className="description-text" title={info.description}>
|
||||
{info.description}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="text"
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수량 */}
|
||||
<div className="material-cell">
|
||||
<div className="quantity-info">
|
||||
<span className="quantity-value">
|
||||
{info.quantity} {info.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 가스켓인 경우 11개 컬럼
|
||||
if (info.isGasket) {
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`detailed-material-row gasket-row ${selectedMaterials.has(material.id) ? 'selected' : ''}`}
|
||||
>
|
||||
{/* 선택 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onChange={() => toggleMaterialSelection(material.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 종류 */}
|
||||
<div className="material-cell">
|
||||
<span className={`type-badge ${info.type.toLowerCase()}`}>
|
||||
{info.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 타입 */}
|
||||
<div className="material-cell">
|
||||
<span className="subtype-text">{info.subtype}</span>
|
||||
</div>
|
||||
|
||||
{/* 크기 */}
|
||||
<div className="material-cell">
|
||||
<span className="size-text">{info.size}</span>
|
||||
</div>
|
||||
|
||||
{/* 압력 */}
|
||||
<div className="material-cell">
|
||||
<span className="pressure-info">{info.pressure}</span>
|
||||
</div>
|
||||
|
||||
{/* 재질 */}
|
||||
<div className="material-cell">
|
||||
<span className="material-structure">{info.materialStructure}</span>
|
||||
</div>
|
||||
|
||||
{/* 상세내역 */}
|
||||
<div className="material-cell">
|
||||
<span className="material-detail">{info.materialDetail}</span>
|
||||
</div>
|
||||
|
||||
{/* 두께 */}
|
||||
<div className="material-cell">
|
||||
<span className="thickness-info">{info.thickness}</span>
|
||||
</div>
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="text"
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수량 */}
|
||||
<div className="material-cell">
|
||||
<div className="quantity-info">
|
||||
<span className="quantity-value">
|
||||
{info.quantity} {info.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 플랜지가 아닌 경우 9개 컬럼
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`detailed-material-row ${selectedMaterials.has(material.id) ? 'selected' : ''}`}
|
||||
>
|
||||
{/* 선택 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onChange={() => toggleMaterialSelection(material.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 종류 */}
|
||||
<div className="material-cell">
|
||||
<span className={`type-badge ${info.type.toLowerCase()}`}>
|
||||
{info.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 타입 */}
|
||||
<div className="material-cell">
|
||||
<span className="subtype-text">{info.subtype}</span>
|
||||
</div>
|
||||
|
||||
{/* 크기 */}
|
||||
<div className="material-cell">
|
||||
<span className="size-text">{info.size}</span>
|
||||
</div>
|
||||
|
||||
{/* 스케줄 */}
|
||||
<div className="material-cell">
|
||||
<span>{info.schedule}</span>
|
||||
</div>
|
||||
|
||||
{/* 재질 */}
|
||||
<div className="material-cell">
|
||||
<span className="material-grade">{info.grade}</span>
|
||||
</div>
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
<div className="material-cell">
|
||||
<input
|
||||
type="text"
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수량 */}
|
||||
<div className="material-cell">
|
||||
<div className="quantity-info">
|
||||
<span className="quantity-value">
|
||||
{info.quantity} {info.unit}
|
||||
</span>
|
||||
{info.type === 'PIPE' && info.details && (
|
||||
<div className="quantity-details">
|
||||
<small>
|
||||
단관 {info.details.pipeCount}개 → {Math.round(info.details.totalLength)}mm
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewMaterialsPage;
|
||||
358
frontend/src/pages/ProjectWorkspacePage.jsx
Normal file
358
frontend/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;
|
||||
@@ -386,3 +386,19 @@ const ProjectsPage = ({ user }) => {
|
||||
};
|
||||
|
||||
export default ProjectsPage;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,446 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Button,
|
||||
TextField,
|
||||
Chip,
|
||||
Alert,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Grid,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
Edit,
|
||||
Check,
|
||||
Close,
|
||||
ShoppingCart,
|
||||
CompareArrows,
|
||||
Warning
|
||||
} from '@mui/icons-material';
|
||||
import { api } from '../api';
|
||||
|
||||
const PurchaseConfirmationPage = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [purchaseItems, setPurchaseItems] = useState([]);
|
||||
const [revisionComparison, setRevisionComparison] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState(false);
|
||||
|
||||
// URL에서 job_no, revision 정보 가져오기
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const jobNo = searchParams.get('job_no');
|
||||
const revision = searchParams.get('revision');
|
||||
const filename = searchParams.get('filename');
|
||||
const previousRevision = searchParams.get('prev_revision');
|
||||
|
||||
useEffect(() => {
|
||||
if (jobNo && revision) {
|
||||
loadPurchaseItems();
|
||||
if (previousRevision) {
|
||||
loadRevisionComparison();
|
||||
}
|
||||
}
|
||||
}, [jobNo, revision, previousRevision]);
|
||||
|
||||
const loadPurchaseItems = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/purchase/items/calculate', {
|
||||
params: { job_no: jobNo, revision: revision }
|
||||
});
|
||||
setPurchaseItems(response.data.items || []);
|
||||
} catch (error) {
|
||||
console.error('구매 품목 로딩 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRevisionComparison = async () => {
|
||||
try {
|
||||
const response = await api.get('/purchase/revision-diff', {
|
||||
params: {
|
||||
job_no: jobNo,
|
||||
current_revision: revision,
|
||||
previous_revision: previousRevision
|
||||
}
|
||||
});
|
||||
setRevisionComparison(response.data.comparison);
|
||||
} catch (error) {
|
||||
console.error('리비전 비교 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateItemQuantity = async (itemId, field, value) => {
|
||||
try {
|
||||
await api.patch(`/purchase/items/${itemId}`, {
|
||||
[field]: parseFloat(value)
|
||||
});
|
||||
|
||||
// 로컬 상태 업데이트
|
||||
setPurchaseItems(prev =>
|
||||
prev.map(item =>
|
||||
item.id === itemId
|
||||
? { ...item, [field]: parseFloat(value) }
|
||||
: item
|
||||
)
|
||||
);
|
||||
|
||||
setEditingItem(null);
|
||||
} catch (error) {
|
||||
console.error('수량 업데이트 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmPurchase = async () => {
|
||||
try {
|
||||
const response = await api.post('/purchase/orders/create', {
|
||||
job_no: jobNo,
|
||||
revision: revision,
|
||||
items: purchaseItems.map(item => ({
|
||||
purchase_item_id: item.id,
|
||||
ordered_quantity: item.calculated_qty,
|
||||
required_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30일 후
|
||||
}))
|
||||
});
|
||||
|
||||
alert('구매 주문이 생성되었습니다!');
|
||||
navigate('/materials', {
|
||||
state: { message: '구매 주문 생성 완료' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('구매 주문 생성 실패:', error);
|
||||
alert('구매 주문 생성에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryColor = (category) => {
|
||||
const colors = {
|
||||
'PIPE': 'primary',
|
||||
'FITTING': 'secondary',
|
||||
'VALVE': 'success',
|
||||
'FLANGE': 'warning',
|
||||
'BOLT': 'info',
|
||||
'GASKET': 'error',
|
||||
'INSTRUMENT': 'purple'
|
||||
};
|
||||
return colors[category] || 'default';
|
||||
};
|
||||
|
||||
const formatPipeInfo = (item) => {
|
||||
if (item.category !== 'PIPE') return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
절단손실: {item.cutting_loss || 0}mm |
|
||||
구매: {item.pipes_count || 0}본 |
|
||||
여유분: {item.waste_length || 0}mm
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const formatBoltInfo = (item) => {
|
||||
if (item.category !== 'BOLT') return null;
|
||||
|
||||
// 특수 용도 볼트 정보 (백엔드에서 제공되어야 함)
|
||||
const specialApplications = item.special_applications || {};
|
||||
const psvCount = specialApplications.PSV || 0;
|
||||
const ltCount = specialApplications.LT || 0;
|
||||
const ckCount = specialApplications.CK || 0;
|
||||
const oriCount = specialApplications.ORI || 0;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
분수 사이즈: {(item.size_fraction || item.size_spec || '').replace(/"/g, '')} |
|
||||
표면처리: {item.surface_treatment || '없음'}
|
||||
</Typography>
|
||||
|
||||
{/* 특수 용도 볼트 정보 */}
|
||||
<Box sx={{ mt: 1, p: 1, bgcolor: 'info.50', borderRadius: 1 }}>
|
||||
<Typography variant="caption" fontWeight="bold" color="info.main">
|
||||
특수 용도 볼트 현황:
|
||||
</Typography>
|
||||
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||
<Grid item xs={3}>
|
||||
<Typography variant="caption" color={psvCount > 0 ? "error.main" : "textSecondary"}>
|
||||
PSV용: {psvCount}개
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={3}>
|
||||
<Typography variant="caption" color={ltCount > 0 ? "warning.main" : "textSecondary"}>
|
||||
저온용: {ltCount}개
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={3}>
|
||||
<Typography variant="caption" color={ckCount > 0 ? "info.main" : "textSecondary"}>
|
||||
체크밸브용: {ckCount}개
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={3}>
|
||||
<Typography variant="caption" color={oriCount > 0 ? "secondary.main" : "textSecondary"}>
|
||||
오리피스용: {oriCount}개
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{(psvCount + ltCount + ckCount + oriCount) === 0 && (
|
||||
<Typography variant="caption" color="success.main" sx={{ fontStyle: 'italic' }}>
|
||||
특수 용도 볼트 없음 (일반 볼트만 포함)
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{/* 헤더 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||
<IconButton onClick={() => navigate(-1)} sx={{ mr: 2 }}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
🛒 구매 확정
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary">
|
||||
Job: {jobNo} | {filename} | {revision}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<ShoppingCart />}
|
||||
onClick={() => setConfirmDialog(true)}
|
||||
size="large"
|
||||
disabled={purchaseItems.length === 0}
|
||||
>
|
||||
구매 주문 생성
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* 리비전 비교 알림 */}
|
||||
{revisionComparison && (
|
||||
<Alert
|
||||
severity={revisionComparison.has_changes ? "warning" : "info"}
|
||||
sx={{ mb: 3 }}
|
||||
icon={<CompareArrows />}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
<strong>리비전 변경사항:</strong> {revisionComparison.summary}
|
||||
</Typography>
|
||||
{revisionComparison.additional_items && (
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
추가 구매 필요: {revisionComparison.additional_items}개 품목
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 구매 품목 테이블 */}
|
||||
{purchaseItems.map(item => (
|
||||
<Card key={item.id} sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Chip
|
||||
label={item.category}
|
||||
color={getCategoryColor(item.category)}
|
||||
sx={{ mr: 2 }}
|
||||
/>
|
||||
<Typography variant="h6" sx={{ flex: 1 }}>
|
||||
{item.specification}
|
||||
</Typography>
|
||||
{item.is_additional && (
|
||||
<Chip
|
||||
label="추가 구매"
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* BOM 수량 */}
|
||||
<Grid item xs={12} md={3}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
BOM 필요량
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{item.bom_quantity} {item.unit}
|
||||
</Typography>
|
||||
{formatPipeInfo(item)}
|
||||
{formatBoltInfo(item)}
|
||||
</Grid>
|
||||
|
||||
{/* 구매 수량 */}
|
||||
<Grid item xs={12} md={3}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
구매 수량
|
||||
</Typography>
|
||||
{editingItem === item.id ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TextField
|
||||
value={item.calculated_qty}
|
||||
onChange={(e) =>
|
||||
setPurchaseItems(prev =>
|
||||
prev.map(i =>
|
||||
i.id === item.id
|
||||
? { ...i, calculated_qty: parseFloat(e.target.value) || 0 }
|
||||
: i
|
||||
)
|
||||
)
|
||||
}
|
||||
size="small"
|
||||
type="number"
|
||||
sx={{ width: 100 }}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => updateItemQuantity(item.id, 'calculated_qty', item.calculated_qty)}
|
||||
>
|
||||
<Check />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setEditingItem(null)}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="h6" color="primary">
|
||||
{item.calculated_qty} {item.unit}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setEditingItem(item.id)}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* 이미 구매한 수량 */}
|
||||
{previousRevision && (
|
||||
<Grid item xs={12} md={3}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
기구매 수량
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{item.purchased_quantity || 0} {item.unit}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* 추가 구매 필요량 */}
|
||||
{previousRevision && (
|
||||
<Grid item xs={12} md={3}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
추가 구매 필요
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
color={item.additional_needed > 0 ? "error" : "success"}
|
||||
>
|
||||
{Math.max(item.additional_needed || 0, 0)} {item.unit}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* 여유율 및 최소 주문 정보 */}
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
여유율
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{((item.safety_factor || 1) - 1) * 100}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
최소 주문
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{item.min_order_qty || 0} {item.unit}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
예상 여유분
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{(item.calculated_qty - item.bom_quantity).toFixed(1)} {item.unit}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
활용률
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{((item.bom_quantity / item.calculated_qty) * 100).toFixed(1)}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* 구매 주문 확인 다이얼로그 */}
|
||||
<Dialog open={confirmDialog} onClose={() => setConfirmDialog(false)}>
|
||||
<DialogTitle>구매 주문 생성 확인</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
총 {purchaseItems.length}개 품목에 대한 구매 주문을 생성하시겠습니까?
|
||||
</Typography>
|
||||
|
||||
{revisionComparison && revisionComparison.has_changes && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
리비전 변경으로 인한 추가 구매가 포함되어 있습니다.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
구매 주문 생성 후에는 수량 변경이 제한됩니다.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmDialog(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={confirmPurchase} variant="contained">
|
||||
주문 생성
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseConfirmationPage;
|
||||
@@ -1,742 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
const SimpleMaterialsPage = ({ fileId, jobNo: propJobNo, bomName: propBomName, revision: propRevision, filename: propFilename, onNavigate }) => {
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [jobNo, setJobNo] = useState('');
|
||||
const [bomName, setBomName] = useState('');
|
||||
const [currentRevision, setCurrentRevision] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterCategory, setFilterCategory] = useState('all');
|
||||
const [filterConfidence, setFilterConfidence] = useState('all');
|
||||
const [showPurchaseCalculation, setShowPurchaseCalculation] = useState(false);
|
||||
const [purchaseData, setPurchaseData] = useState(null);
|
||||
const [calculatingPurchase, setCalculatingPurchase] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Props로 받은 값들을 초기화
|
||||
if (propJobNo) setJobNo(propJobNo);
|
||||
if (propBomName) setBomName(propBomName);
|
||||
if (propRevision) setCurrentRevision(propRevision);
|
||||
if (propFilename) setFileName(propFilename);
|
||||
|
||||
if (fileId) {
|
||||
loadMaterials(fileId);
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError('파일 ID가 지정되지 않았습니다.');
|
||||
}
|
||||
}, [fileId, propJobNo, propBomName, propRevision, propFilename]);
|
||||
|
||||
const loadMaterials = async (id) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/files/materials', {
|
||||
params: { file_id: parseInt(id), limit: 10000 }
|
||||
});
|
||||
|
||||
if (response.data && response.data.materials) {
|
||||
setMaterials(response.data.materials);
|
||||
|
||||
// 파일 정보 설정
|
||||
if (response.data.materials.length > 0) {
|
||||
const firstMaterial = response.data.materials[0];
|
||||
setFileName(firstMaterial.filename || '');
|
||||
setJobNo(firstMaterial.project_code || '');
|
||||
setBomName(firstMaterial.filename || '');
|
||||
setCurrentRevision('Rev.0'); // API에서 revision 정보가 없으므로 기본값
|
||||
}
|
||||
} else {
|
||||
setMaterials([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('자재 목록 로드 실패:', err);
|
||||
setError('자재 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 구매 수량 계산 함수 (기존 BOM 규칙 적용)
|
||||
const calculatePurchaseQuantities = async () => {
|
||||
if (!jobNo || !currentRevision) {
|
||||
alert('프로젝트 정보가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setCalculatingPurchase(true);
|
||||
try {
|
||||
const response = await api.get(`/purchase/calculate`, {
|
||||
params: {
|
||||
job_no: jobNo,
|
||||
revision: currentRevision,
|
||||
file_id: fileId
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
setPurchaseData(response.data.purchase_items);
|
||||
setShowPurchaseCalculation(true);
|
||||
} else {
|
||||
throw new Error('구매 수량 계산 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('구매 수량 계산 오류:', error);
|
||||
alert('구매 수량 계산에 실패했습니다.');
|
||||
} finally {
|
||||
setCalculatingPurchase(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 필터링된 자재 목록 (기존 BOM 규칙 적용)
|
||||
const filteredMaterials = materials.filter(material => {
|
||||
const matchesSearch = !searchTerm ||
|
||||
material.original_description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.size_spec?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.material_grade?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesCategory = filterCategory === 'all' ||
|
||||
material.classified_category === filterCategory;
|
||||
|
||||
// 신뢰도 필터링 (기존 BOM 규칙)
|
||||
const matchesConfidence = filterConfidence === 'all' ||
|
||||
(filterConfidence === 'high' && material.classification_confidence >= 0.9) ||
|
||||
(filterConfidence === 'medium' && material.classification_confidence >= 0.7 && material.classification_confidence < 0.9) ||
|
||||
(filterConfidence === 'low' && material.classification_confidence < 0.7);
|
||||
|
||||
return matchesSearch && matchesCategory && matchesConfidence;
|
||||
});
|
||||
|
||||
// 카테고리별 통계
|
||||
const categoryStats = materials.reduce((acc, material) => {
|
||||
const category = material.classified_category || 'unknown';
|
||||
acc[category] = (acc[category] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const categories = Object.keys(categoryStats).sort();
|
||||
|
||||
// 카테고리별 색상 함수
|
||||
const getCategoryColor = (category) => {
|
||||
const colors = {
|
||||
'pipe': '#4299e1',
|
||||
'fitting': '#48bb78',
|
||||
'valve': '#ed8936',
|
||||
'flange': '#9f7aea',
|
||||
'bolt': '#38b2ac',
|
||||
'gasket': '#f56565',
|
||||
'instrument': '#d69e2e',
|
||||
'material': '#718096',
|
||||
'integrated': '#319795',
|
||||
'unknown': '#a0aec0'
|
||||
};
|
||||
return colors[category?.toLowerCase()] || colors.unknown;
|
||||
};
|
||||
|
||||
// 신뢰도 배지 함수 (기존 BOM 규칙 적용)
|
||||
const getConfidenceBadge = (confidence) => {
|
||||
if (!confidence) return '-';
|
||||
|
||||
const conf = parseFloat(confidence);
|
||||
let color, text;
|
||||
|
||||
if (conf >= 0.9) {
|
||||
color = '#48bb78'; // 녹색
|
||||
text = '높음';
|
||||
} else if (conf >= 0.7) {
|
||||
color = '#ed8936'; // 주황색
|
||||
text = '보통';
|
||||
} else {
|
||||
color = '#f56565'; // 빨간색
|
||||
text = '낮음';
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span style={{
|
||||
background: color,
|
||||
color: 'white',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{text}
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: '#718096' }}>
|
||||
{Math.round(conf * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 상세정보 표시 함수 (기존 BOM 규칙 적용)
|
||||
const getDetailInfo = (material) => {
|
||||
const details = [];
|
||||
|
||||
// PIPE 상세정보
|
||||
if (material.pipe_details) {
|
||||
const pipe = material.pipe_details;
|
||||
if (pipe.schedule) details.push(`SCH ${pipe.schedule}`);
|
||||
if (pipe.manufacturing_method) details.push(pipe.manufacturing_method);
|
||||
if (pipe.end_preparation) details.push(pipe.end_preparation);
|
||||
}
|
||||
|
||||
// FITTING 상세정보
|
||||
if (material.fitting_details) {
|
||||
const fitting = material.fitting_details;
|
||||
if (fitting.fitting_type) details.push(fitting.fitting_type);
|
||||
if (fitting.connection_method && fitting.connection_method !== 'UNKNOWN') {
|
||||
details.push(fitting.connection_method);
|
||||
}
|
||||
if (fitting.pressure_rating && fitting.pressure_rating !== 'UNKNOWN') {
|
||||
details.push(fitting.pressure_rating);
|
||||
}
|
||||
}
|
||||
|
||||
// VALVE 상세정보
|
||||
if (material.valve_details) {
|
||||
const valve = material.valve_details;
|
||||
if (valve.valve_type) details.push(valve.valve_type);
|
||||
if (valve.connection_type) details.push(valve.connection_type);
|
||||
if (valve.pressure_rating) details.push(valve.pressure_rating);
|
||||
}
|
||||
|
||||
// BOLT 상세정보
|
||||
if (material.bolt_details) {
|
||||
const bolt = material.bolt_details;
|
||||
if (bolt.fastener_type) details.push(bolt.fastener_type);
|
||||
if (bolt.thread_specification) details.push(bolt.thread_specification);
|
||||
if (bolt.length_mm) details.push(`L${bolt.length_mm}mm`);
|
||||
}
|
||||
|
||||
// FLANGE 상세정보
|
||||
if (material.flange_details) {
|
||||
const flange = material.flange_details;
|
||||
if (flange.flange_type) details.push(flange.flange_type);
|
||||
if (flange.pressure_rating) details.push(flange.pressure_rating);
|
||||
if (flange.facing_type) details.push(flange.facing_type);
|
||||
}
|
||||
|
||||
return details.length > 0 ? (
|
||||
<div style={{ fontSize: '11px', color: '#4a5568' }}>
|
||||
{details.slice(0, 2).map((detail, idx) => (
|
||||
<div key={idx} style={{
|
||||
background: '#f7fafc',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '3px',
|
||||
marginBottom: '2px',
|
||||
display: 'inline-block',
|
||||
marginRight: '4px'
|
||||
}}>
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
{details.length > 2 && (
|
||||
<span style={{ color: '#718096' }}>+{details.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
) : '-';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ padding: '40px' }}>
|
||||
로딩 중...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#fed7d7',
|
||||
border: '1px solid #fc8181',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
color: '#c53030'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<button
|
||||
onClick={() => onNavigate && onNavigate('bom-status', { job_no: jobNo, job_name: bomName })}
|
||||
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'
|
||||
}}>
|
||||
📦 자재 목록
|
||||
</h1>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
margin: '0 0 24px 0'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
<div><strong>프로젝트:</strong> {jobNo}</div>
|
||||
<div><strong>BOM:</strong> {bomName}</div>
|
||||
<div><strong>리비전:</strong> {currentRevision}</div>
|
||||
<div><strong>총 자재 수:</strong> {materials.length}개</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={calculatePurchaseQuantities}
|
||||
disabled={calculatingPurchase}
|
||||
style={{
|
||||
background: calculatingPurchase ? '#a0aec0' : '#48bb78',
|
||||
color: 'white',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: calculatingPurchase ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
{calculatingPurchase ? '계산중...' : '🧮 구매수량 계산'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: '24px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 200px 200px',
|
||||
gap: '16px',
|
||||
alignItems: 'end'
|
||||
}}>
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
자재 검색
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="자재명, 규격, 설명으로 검색..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
카테고리 필터
|
||||
</label>
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
background: 'white'
|
||||
}}
|
||||
>
|
||||
<option value="all">전체 ({materials.length})</option>
|
||||
{categories.map(category => (
|
||||
<option key={category} value={category}>
|
||||
{category} ({categoryStats[category]})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
신뢰도 필터
|
||||
</label>
|
||||
<select
|
||||
value={filterConfidence}
|
||||
onChange={(e) => setFilterConfidence(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
background: 'white'
|
||||
}}
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
<option value="high">높음 (90%+)</option>
|
||||
<option value="medium">보통 (70-89%)</option>
|
||||
<option value="low">낮음 (70% 미만)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '16px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
{categories.slice(0, 6).map(category => (
|
||||
<div key={category} style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: '16px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#4299e1' }}>
|
||||
{categoryStats[category]}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#718096', marginTop: '4px' }}>
|
||||
{category}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 자재 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{ padding: '20px', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<h3 style={{ margin: '0', fontSize: '18px', fontWeight: '600' }}>
|
||||
자재 목록 ({filteredMaterials.length}개)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f7fafc' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>No.</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>자재명</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>규격</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>수량</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>단위</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>카테고리</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>재질</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>신뢰도</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>상세정보</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredMaterials.map((material, index) => (
|
||||
<tr key={material.id || index} style={{
|
||||
borderBottom: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{material.line_number || index + 1}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px', fontWeight: '500' }}>
|
||||
{material.original_description || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{material.size_spec || material.main_nom || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right' }}>
|
||||
{material.quantity || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{material.unit || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
<span style={{
|
||||
background: getCategoryColor(material.classified_category),
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{material.classified_category || 'unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{material.material_grade || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{getConfidenceBadge(material.classification_confidence)}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{getDetailInfo(material)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
color: '#718096'
|
||||
}}>
|
||||
검색 조건에 맞는 자재가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 구매 수량 계산 결과 모달 */}
|
||||
{showPurchaseCalculation && purchaseData && (
|
||||
<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: '1000px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
margin: '20px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: 0
|
||||
}}>
|
||||
🧮 구매 수량 계산 결과
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowPurchaseCalculation(false)}
|
||||
style={{
|
||||
background: '#e2e8f0',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
✕ 닫기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f7fafc' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>카테고리</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사양</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사이즈</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>재질</th>
|
||||
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>BOM 수량</th>
|
||||
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>구매 수량</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>단위</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{purchaseData.map((item, index) => (
|
||||
<tr key={index} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
<span style={{
|
||||
background: getCategoryColor(item.category),
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{item.category}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{item.specification}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{/* PIPE는 사양에 모든 정보가 포함되므로 사이즈 컬럼 비움 */}
|
||||
{item.category !== 'PIPE' && (
|
||||
<span style={{
|
||||
background: '#e6fffa',
|
||||
color: '#065f46',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{item.size_spec || '-'}
|
||||
</span>
|
||||
)}
|
||||
{item.category === 'PIPE' && (
|
||||
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||
사양에 포함
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{/* PIPE는 사양에 모든 정보가 포함되므로 재질 컬럼 비움 */}
|
||||
{item.category !== 'PIPE' && (
|
||||
<span style={{
|
||||
background: '#fef7e0',
|
||||
color: '#92400e',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{item.material_spec || '-'}
|
||||
</span>
|
||||
)}
|
||||
{item.category === 'PIPE' && (
|
||||
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||
사양에 포함
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right' }}>
|
||||
{item.category === 'PIPE' ?
|
||||
`${Math.round(item.bom_quantity)}mm` :
|
||||
item.bom_quantity
|
||||
}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right', fontWeight: '600' }}>
|
||||
{item.category === 'PIPE' ?
|
||||
`${item.pipes_count}본 (${Math.round(item.calculated_qty)}mm)` :
|
||||
item.calculated_qty
|
||||
}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{item.unit}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '12px', color: '#718096' }}>
|
||||
{item.category === 'PIPE' && (
|
||||
<div>
|
||||
<div>절단수: {item.cutting_count}회</div>
|
||||
<div>절단손실: {item.cutting_loss}mm</div>
|
||||
<div>활용률: {Math.round(item.utilization_rate)}%</div>
|
||||
</div>
|
||||
)}
|
||||
{item.category !== 'PIPE' && item.safety_factor && (
|
||||
<div>여유율: {Math.round((item.safety_factor - 1) * 100)}%</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '24px',
|
||||
padding: '16px',
|
||||
background: '#f7fafc',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#4a5568'
|
||||
}}>
|
||||
<div style={{ fontWeight: '600', marginBottom: '8px' }}>📋 계산 규칙 (올바른 규칙):</div>
|
||||
<div>• <strong>PIPE:</strong> 6M 단위 올림, 절단당 2mm 손실</div>
|
||||
<div>• <strong>FITTING:</strong> BOM 수량 그대로</div>
|
||||
<div>• <strong>VALVE:</strong> BOM 수량 그대로</div>
|
||||
<div>• <strong>BOLT:</strong> 5% 여유율 후 4의 배수 올림</div>
|
||||
<div>• <strong>GASKET:</strong> 5의 배수 올림</div>
|
||||
<div>• <strong>INSTRUMENT:</strong> BOM 수량 그대로</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleMaterialsPage;
|
||||
455
frontend/src/pages/SystemSettingsPage.jsx
Normal file
455
frontend/src/pages/SystemSettingsPage.jsx
Normal file
@@ -0,0 +1,455 @@
|
||||
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 [newUser, setNewUser] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
full_name: '',
|
||||
role: 'user'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
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 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';
|
||||
}
|
||||
};
|
||||
|
||||
// 관리자 권한 확인
|
||||
if (user?.role !== 'admin') {
|
||||
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>
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemSettingsPage;
|
||||
@@ -428,3 +428,19 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import SimpleFileUpload from '../components/SimpleFileUpload';
|
||||
import MaterialList from '../components/MaterialList';
|
||||
import { fetchMaterials, fetchFiles } from '../api';
|
||||
import { fetchMaterials, fetchFiles, fetchJobs } from '../api';
|
||||
|
||||
const BOMManagementPage = ({ user }) => {
|
||||
const [activeTab, setActiveTab] = useState('upload');
|
||||
@@ -32,10 +32,10 @@ const BOMManagementPage = ({ user }) => {
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/jobs/');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setProjects(data.jobs);
|
||||
// ✅ API 함수 사용 (권장)
|
||||
const response = await fetchJobs();
|
||||
if (response.data.success) {
|
||||
setProjects(response.data.jobs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로딩 실패:', error);
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import MaterialComparisonResult from '../components/MaterialComparisonResult';
|
||||
import { compareMaterialRevisions, confirmMaterialPurchase, api } from '../api';
|
||||
import { compareMaterialRevisions, confirmMaterialPurchase, fetchMaterials, api } from '../api';
|
||||
import { exportComparisonToExcel } from '../utils/excelExport';
|
||||
|
||||
const MaterialComparisonPage = () => {
|
||||
@@ -74,8 +74,11 @@ const MaterialComparisonPage = () => {
|
||||
|
||||
// 🚨 테스트: MaterialsPage API 직접 호출해서 길이 정보 확인
|
||||
try {
|
||||
const testResult = await api.get('/files/materials', {
|
||||
params: { job_no: jobNo, revision: currentRevision, limit: 10 }
|
||||
// ✅ API 함수 사용 - 테스트용 자재 조회
|
||||
const testResult = await fetchMaterials({
|
||||
job_no: jobNo,
|
||||
revision: currentRevision,
|
||||
limit: 10
|
||||
});
|
||||
const pipeData = testResult.data.materials?.filter(m => m.classified_category === 'PIPE');
|
||||
console.log('🧪 MaterialsPage API 테스트 (길이 있는지 확인):', pipeData);
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import MaterialList from '../components/MaterialList';
|
||||
import { fetchMaterials } from '../api';
|
||||
import { fetchMaterials, fetchJobs } from '../api';
|
||||
|
||||
const MaterialsManagementPage = ({ user }) => {
|
||||
const [materials, setMaterials] = useState([]);
|
||||
@@ -31,10 +31,10 @@ const MaterialsManagementPage = ({ user }) => {
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/jobs/');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setProjects(data.jobs);
|
||||
// ✅ API 함수 사용 (권장)
|
||||
const response = await fetchJobs();
|
||||
if (response.data.success) {
|
||||
setProjects(response.data.jobs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로딩 실패:', error);
|
||||
736
frontend/src/pages/_backup/PurchaseConfirmationPage.jsx
Normal file
736
frontend/src/pages/_backup/PurchaseConfirmationPage.jsx
Normal file
@@ -0,0 +1,736 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api';
|
||||
|
||||
const PurchaseConfirmationPage = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [purchaseItems, setPurchaseItems] = useState([]);
|
||||
const [revisionComparison, setRevisionComparison] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState(false);
|
||||
|
||||
// URL에서 job_no, revision 정보 가져오기
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const jobNo = searchParams.get('job_no');
|
||||
const revision = searchParams.get('revision');
|
||||
const filename = searchParams.get('filename');
|
||||
const previousRevision = searchParams.get('prev_revision');
|
||||
|
||||
useEffect(() => {
|
||||
if (jobNo && revision) {
|
||||
loadPurchaseItems();
|
||||
if (previousRevision) {
|
||||
loadRevisionComparison();
|
||||
}
|
||||
}
|
||||
}, [jobNo, revision, previousRevision]);
|
||||
|
||||
const loadPurchaseItems = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/purchase/items/calculate', {
|
||||
params: { job_no: jobNo, revision: revision }
|
||||
});
|
||||
setPurchaseItems(response.data.items || []);
|
||||
} catch (error) {
|
||||
console.error('구매 품목 로딩 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRevisionComparison = async () => {
|
||||
try {
|
||||
const response = await api.get('/purchase/revision-diff', {
|
||||
params: {
|
||||
job_no: jobNo,
|
||||
current_revision: revision,
|
||||
previous_revision: previousRevision
|
||||
}
|
||||
});
|
||||
setRevisionComparison(response.data.comparison);
|
||||
} catch (error) {
|
||||
console.error('리비전 비교 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateItemQuantity = async (itemId, field, value) => {
|
||||
try {
|
||||
await api.patch(`/purchase/items/${itemId}`, {
|
||||
[field]: parseFloat(value)
|
||||
});
|
||||
|
||||
// 로컬 상태 업데이트
|
||||
setPurchaseItems(prev =>
|
||||
prev.map(item =>
|
||||
item.id === itemId
|
||||
? { ...item, [field]: parseFloat(value) }
|
||||
: item
|
||||
)
|
||||
);
|
||||
|
||||
setEditingItem(null);
|
||||
} catch (error) {
|
||||
console.error('수량 업데이트 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmPurchase = async () => {
|
||||
try {
|
||||
// 입력 데이터 검증
|
||||
if (!jobNo || !revision) {
|
||||
alert('Job 번호와 리비전 정보가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (purchaseItems.length === 0) {
|
||||
alert('구매할 품목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 각 품목의 수량 검증
|
||||
const invalidItems = purchaseItems.filter(item =>
|
||||
!item.calculated_qty || item.calculated_qty <= 0
|
||||
);
|
||||
|
||||
if (invalidItems.length > 0) {
|
||||
alert(`다음 품목들의 구매 수량이 유효하지 않습니다:\n${invalidItems.map(item => `- ${item.specification}`).join('\n')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setConfirmDialog(false);
|
||||
|
||||
const response = await api.post('/purchase/orders/create', {
|
||||
job_no: jobNo,
|
||||
revision: revision,
|
||||
items: purchaseItems.map(item => ({
|
||||
purchase_item_id: item.id,
|
||||
ordered_quantity: item.calculated_qty,
|
||||
required_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30일 후
|
||||
}))
|
||||
});
|
||||
|
||||
const successMessage = `구매 주문이 성공적으로 생성되었습니다!\n\n` +
|
||||
`- Job: ${jobNo}\n` +
|
||||
`- Revision: ${revision}\n` +
|
||||
`- 품목 수: ${purchaseItems.length}개\n` +
|
||||
`- 생성 시간: ${new Date().toLocaleString('ko-KR')}`;
|
||||
|
||||
alert(successMessage);
|
||||
|
||||
// 자재 목록 페이지로 이동 (상태 기반 라우팅 사용)
|
||||
// App.jsx의 상태 기반 라우팅을 위해 window 이벤트 발생
|
||||
window.dispatchEvent(new CustomEvent('navigateToMaterials', {
|
||||
detail: {
|
||||
jobNo: jobNo,
|
||||
revision: revision,
|
||||
bomName: `${jobNo} ${revision}`,
|
||||
message: '구매 주문 생성 완료'
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('구매 주문 생성 실패:', error);
|
||||
|
||||
let errorMessage = '구매 주문 생성에 실패했습니다.';
|
||||
|
||||
if (error.response?.data?.detail) {
|
||||
errorMessage += `\n\n오류 내용: ${error.response.data.detail}`;
|
||||
} else if (error.message) {
|
||||
errorMessage += `\n\n오류 내용: ${error.message}`;
|
||||
}
|
||||
|
||||
if (error.response?.status === 400) {
|
||||
errorMessage += '\n\n입력 데이터를 확인해주세요.';
|
||||
} else if (error.response?.status === 404) {
|
||||
errorMessage += '\n\n해당 Job이나 리비전을 찾을 수 없습니다.';
|
||||
} else if (error.response?.status >= 500) {
|
||||
errorMessage += '\n\n서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
||||
}
|
||||
|
||||
alert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryColor = (category) => {
|
||||
const colors = {
|
||||
'PIPE': '#1976d2',
|
||||
'FITTING': '#9c27b0',
|
||||
'VALVE': '#2e7d32',
|
||||
'FLANGE': '#ed6c02',
|
||||
'BOLT': '#0288d1',
|
||||
'GASKET': '#d32f2f',
|
||||
'INSTRUMENT': '#7b1fa2'
|
||||
};
|
||||
return colors[category] || '#757575';
|
||||
};
|
||||
|
||||
const formatPipeInfo = (item) => {
|
||||
if (item.category !== 'PIPE') return null;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '8px', fontSize: '12px', color: '#666' }}>
|
||||
절단손실: {item.cutting_loss || 0}mm |
|
||||
구매: {item.pipes_count || 0}본 |
|
||||
여유분: {item.waste_length || 0}mm
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const formatBoltInfo = (item) => {
|
||||
if (item.category !== 'BOLT') return null;
|
||||
|
||||
// 특수 용도 볼트 정보 (백엔드에서 제공되어야 함)
|
||||
const specialApplications = item.special_applications || {};
|
||||
const psvCount = specialApplications.PSV || 0;
|
||||
const ltCount = specialApplications.LT || 0;
|
||||
const ckCount = specialApplications.CK || 0;
|
||||
const oriCount = specialApplications.ORI || 0;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||
분수 사이즈: {(item.size_fraction || item.size_spec || '').replace(/"/g, '')} |
|
||||
표면처리: {item.surface_treatment || '없음'}
|
||||
</div>
|
||||
|
||||
{/* 특수 용도 볼트 정보 */}
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
padding: '8px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', fontWeight: 'bold', color: '#0288d1' }}>
|
||||
특수 용도 볼트 현황:
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: '8px',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', color: psvCount > 0 ? '#d32f2f' : '#666' }}>
|
||||
PSV용: {psvCount}개
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: ltCount > 0 ? '#ed6c02' : '#666' }}>
|
||||
저온용: {ltCount}개
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: ckCount > 0 ? '#0288d1' : '#666' }}>
|
||||
체크밸브용: {ckCount}개
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: oriCount > 0 ? '#9c27b0' : '#666' }}>
|
||||
오리피스용: {oriCount}개
|
||||
</div>
|
||||
</div>
|
||||
{(psvCount + ltCount + ckCount + oriCount) === 0 && (
|
||||
<div style={{ fontSize: '12px', color: '#2e7d32', fontStyle: 'italic' }}>
|
||||
특수 용도 볼트 없음 (일반 볼트만 포함)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const exportToExcel = () => {
|
||||
if (purchaseItems.length === 0) {
|
||||
alert('내보낼 구매 품목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 상세한 구매 확정 데이터 생성
|
||||
const data = purchaseItems.map((item, index) => {
|
||||
const baseData = {
|
||||
'순번': index + 1,
|
||||
'품목코드': item.item_code || '',
|
||||
'카테고리': item.category || '',
|
||||
'사양': item.specification || '',
|
||||
'재질': item.material_spec || '',
|
||||
'사이즈': item.size_spec || '',
|
||||
'단위': item.unit || '',
|
||||
'BOM수량': item.bom_quantity || 0,
|
||||
'구매수량': item.calculated_qty || 0,
|
||||
'여유율': ((item.safety_factor || 1) - 1) * 100 + '%',
|
||||
'최소주문': item.min_order_qty || 0,
|
||||
'예상여유분': ((item.calculated_qty || 0) - (item.bom_quantity || 0)).toFixed(1),
|
||||
'활용률': (((item.bom_quantity || 0) / (item.calculated_qty || 1)) * 100).toFixed(1) + '%'
|
||||
};
|
||||
|
||||
// 파이프 특수 정보 추가
|
||||
if (item.category === 'PIPE') {
|
||||
baseData['절단손실'] = item.cutting_loss || 0;
|
||||
baseData['구매본수'] = item.pipes_count || 0;
|
||||
baseData['여유길이'] = item.waste_length || 0;
|
||||
}
|
||||
|
||||
// 볼트 특수 정보 추가
|
||||
if (item.category === 'BOLT') {
|
||||
const specialApps = item.special_applications || {};
|
||||
baseData['PSV용'] = specialApps.PSV || 0;
|
||||
baseData['저온용'] = specialApps.LT || 0;
|
||||
baseData['체크밸브용'] = specialApps.CK || 0;
|
||||
baseData['오리피스용'] = specialApps.ORI || 0;
|
||||
baseData['분수사이즈'] = item.size_fraction || '';
|
||||
baseData['표면처리'] = item.surface_treatment || '';
|
||||
}
|
||||
|
||||
// 리비전 비교 정보 추가 (있는 경우)
|
||||
if (previousRevision) {
|
||||
baseData['기구매수량'] = item.purchased_quantity || 0;
|
||||
baseData['추가구매필요'] = Math.max(item.additional_needed || 0, 0);
|
||||
}
|
||||
|
||||
return baseData;
|
||||
});
|
||||
|
||||
// 헤더 정보 추가
|
||||
const headerInfo = [
|
||||
`구매 확정서`,
|
||||
`Job No: ${jobNo}`,
|
||||
`Revision: ${revision}`,
|
||||
`파일명: ${filename || ''}`,
|
||||
`생성일: ${new Date().toLocaleString('ko-KR')}`,
|
||||
`총 품목수: ${purchaseItems.length}개`,
|
||||
''
|
||||
];
|
||||
|
||||
// 요약 정보 계산
|
||||
const totalBomQty = purchaseItems.reduce((sum, item) => sum + (item.bom_quantity || 0), 0);
|
||||
const totalPurchaseQty = purchaseItems.reduce((sum, item) => sum + (item.calculated_qty || 0), 0);
|
||||
const categoryCount = purchaseItems.reduce((acc, item) => {
|
||||
acc[item.category] = (acc[item.category] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const summaryInfo = [
|
||||
'=== 요약 정보 ===',
|
||||
`전체 BOM 수량: ${totalBomQty.toFixed(1)}`,
|
||||
`전체 구매 수량: ${totalPurchaseQty.toFixed(1)}`,
|
||||
`카테고리별 품목수: ${Object.entries(categoryCount).map(([cat, count]) => `${cat}(${count})`).join(', ')}`,
|
||||
''
|
||||
];
|
||||
|
||||
// CSV 형태로 데이터 구성
|
||||
const csvContent = [
|
||||
...headerInfo,
|
||||
...summaryInfo,
|
||||
'=== 상세 품목 목록 ===',
|
||||
Object.keys(data[0]).join(','),
|
||||
...data.map(row => Object.values(row).map(val =>
|
||||
typeof val === 'string' && val.includes(',') ? `"${val}"` : val
|
||||
).join(','))
|
||||
].join('\n');
|
||||
|
||||
// 파일 다운로드
|
||||
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
const fileName = `구매확정서_${jobNo}_${revision}_${timestamp}.csv`;
|
||||
link.setAttribute('download', fileName);
|
||||
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// 성공 메시지
|
||||
alert(`구매 확정서가 다운로드되었습니다.\n파일명: ${fileName}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '200px'
|
||||
}}>
|
||||
<div>로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
marginRight: '16px',
|
||||
padding: '8px'
|
||||
}}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h1 style={{ margin: '0 0 8px 0', fontSize: '28px', fontWeight: 'bold' }}>
|
||||
🛒 구매 확정
|
||||
</h1>
|
||||
<h2 style={{ margin: 0, fontSize: '18px', color: '#1976d2' }}>
|
||||
Job: {jobNo} | {filename} | {revision}
|
||||
</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={exportToExcel}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
backgroundColor: '#2e7d32',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
📊 엑셀 내보내기
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDialog(true)}
|
||||
disabled={purchaseItems.length === 0}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
backgroundColor: purchaseItems.length === 0 ? '#ccc' : '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: purchaseItems.length === 0 ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
🛒 구매 주문 생성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리비전 비교 알림 */}
|
||||
{revisionComparison && (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
backgroundColor: revisionComparison.has_changes ? '#fff3e0' : '#e3f2fd',
|
||||
border: `1px solid ${revisionComparison.has_changes ? '#ed6c02' : '#0288d1'}`,
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span style={{ marginRight: '8px', fontSize: '20px' }}>🔄</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>
|
||||
리비전 변경사항: {revisionComparison.summary}
|
||||
</div>
|
||||
{revisionComparison.additional_items && (
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
추가 구매 필요: {revisionComparison.additional_items}개 품목
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구매 품목 목록 */}
|
||||
{purchaseItems.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '48px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '8px'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📦</div>
|
||||
<div style={{ fontSize: '18px', color: '#666' }}>
|
||||
구매할 품목이 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{purchaseItems.map(item => (
|
||||
<div key={item.id} style={{
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '4px 12px',
|
||||
backgroundColor: getCategoryColor(item.category),
|
||||
color: 'white',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
marginRight: '16px'
|
||||
}}>
|
||||
{item.category}
|
||||
</span>
|
||||
<h3 style={{ margin: 0, flex: 1, fontSize: '18px' }}>
|
||||
{item.specification}
|
||||
</h3>
|
||||
{item.is_additional && (
|
||||
<span style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#fff3e0',
|
||||
color: '#ed6c02',
|
||||
border: '1px solid #ed6c02',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
추가 구매
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '24px'
|
||||
}}>
|
||||
{/* BOM 수량 */}
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
BOM 필요량
|
||||
</div>
|
||||
<div style={{ fontSize: '20px', fontWeight: 'bold' }}>
|
||||
{item.bom_quantity} {item.unit}
|
||||
</div>
|
||||
{formatPipeInfo(item)}
|
||||
{formatBoltInfo(item)}
|
||||
</div>
|
||||
|
||||
{/* 구매 수량 */}
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
구매 수량
|
||||
</div>
|
||||
{editingItem === item.id ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="number"
|
||||
value={item.calculated_qty}
|
||||
onChange={(e) =>
|
||||
setPurchaseItems(prev =>
|
||||
prev.map(i =>
|
||||
i.id === item.id
|
||||
? { ...i, calculated_qty: parseFloat(e.target.value) || 0 }
|
||||
: i
|
||||
)
|
||||
)
|
||||
}
|
||||
style={{
|
||||
width: '100px',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateItemQuantity(item.id, 'calculated_qty', item.calculated_qty)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#1976d2',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingItem(null)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: 'bold', color: '#1976d2' }}>
|
||||
{item.calculated_qty} {item.unit}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEditingItem(item.id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이미 구매한 수량 */}
|
||||
{previousRevision && (
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
기구매 수량
|
||||
</div>
|
||||
<div style={{ fontSize: '20px', fontWeight: 'bold' }}>
|
||||
{item.purchased_quantity || 0} {item.unit}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가 구매 필요량 */}
|
||||
{previousRevision && (
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
추가 구매 필요
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: item.additional_needed > 0 ? '#d32f2f' : '#2e7d32'
|
||||
}}>
|
||||
{Math.max(item.additional_needed || 0, 0)} {item.unit}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 여유율 및 최소 주문 정보 */}
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>여유율</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
|
||||
{((item.safety_factor || 1) - 1) * 100}%
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>최소 주문</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
|
||||
{item.min_order_qty || 0} {item.unit}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>예상 여유분</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
|
||||
{(item.calculated_qty - item.bom_quantity).toFixed(1)} {item.unit}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>활용률</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
|
||||
{((item.bom_quantity / item.calculated_qty) * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구매 주문 확인 다이얼로그 */}
|
||||
{confirmDialog && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
minWidth: '400px',
|
||||
maxWidth: '500px'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0' }}>구매 주문 생성 확인</h3>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
총 {purchaseItems.length}개 품목에 대한 구매 주문을 생성하시겠습니까?
|
||||
</div>
|
||||
|
||||
{revisionComparison && revisionComparison.has_changes && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
marginBottom: '16px',
|
||||
backgroundColor: '#fff3e0',
|
||||
border: '1px solid #ed6c02',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
⚠️ 리비전 변경으로 인한 추가 구매가 포함되어 있습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '24px' }}>
|
||||
구매 주문 생성 후에는 수량 변경이 제한됩니다.
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||
<button
|
||||
onClick={() => setConfirmDialog(false)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'white',
|
||||
color: '#666',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmPurchase}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
주문 생성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseConfirmationPage;
|
||||
@@ -133,6 +133,25 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
.trim();
|
||||
}
|
||||
|
||||
// 니플의 경우 길이 정보 명시적 추가
|
||||
if (category === 'FITTING' && cleanDescription.toLowerCase().includes('nipple')) {
|
||||
// fitting_details에서 길이 정보 가져오기
|
||||
if (material.fitting_details && material.fitting_details.length_mm) {
|
||||
const lengthMm = Math.round(material.fitting_details.length_mm);
|
||||
// 이미 길이 정보가 있는지 확인
|
||||
if (!cleanDescription.match(/\d+\s*mm/i)) {
|
||||
cleanDescription += ` ${lengthMm}mm`;
|
||||
}
|
||||
}
|
||||
// 또는 기존 설명에서 길이 정보 추출
|
||||
else {
|
||||
const lengthMatch = material.original_description?.match(/(\d+)\s*mm/i);
|
||||
if (lengthMatch && !cleanDescription.match(/\d+\s*mm/i)) {
|
||||
cleanDescription += ` ${lengthMatch[1]}mm`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 구매 수량 계산
|
||||
const purchaseInfo = calculatePurchaseQuantity(material);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
port: 13000,
|
||||
host: true,
|
||||
open: true
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user