feat: 자재 관리 페이지 대규모 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- 파이프 수량 계산 로직 수정 (단관 개수가 아닌 실제 길이 기반 계산)
- UI 전면 개편 (DevonThink 스타일의 간결하고 세련된 디자인)
- 자재별 그룹핑 로직 개선:
  * 플랜지: 동일 사양별 그룹핑, WN 스케줄 표시, ORIFICE 풀네임 표시
  * 피팅: 상세 타입 표시 (니플 길이, 엘보 각도/연결, 티 타입, 리듀서 타입 등)
  * 밸브: 동일 사양별 그룹핑, 타입/연결방식/압력 표시
  * 볼트: 크기/재질/길이별 그룹핑 (8SET → 개별 집계)
  * 가스켓: 동일 사양별 그룹핑, 재질/상세내역/두께 분리 표시
  * UNKNOWN: 원본 설명 전체 표시, 동일 항목 그룹핑
- 전체 카테고리 버튼 제거 (표시 복잡도 감소)
- 카테고리별 동적 컬럼 헤더 및 레이아웃 적용
This commit is contained in:
Hyungi Ahn
2025-09-09 09:24:45 +09:00
parent 4f8e395f87
commit 83b90ef05c
101 changed files with 10841 additions and 4813 deletions

View File

@@ -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;
}
# 정적 파일 캐싱

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

View File

@@ -218,3 +218,19 @@ const SimpleDashboard = () => {
};
export default SimpleDashboard;

View File

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

View File

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

View File

@@ -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)}

View File

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

View File

@@ -0,0 +1,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;

View File

@@ -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 목록 조회

View File

@@ -116,3 +116,19 @@ const BOMFileUpload = ({
};
export default BOMFileUpload;

View File

@@ -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 />}
>
자재 목록 보기

View File

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

View File

@@ -535,3 +535,19 @@
right: 12px;
}
}

View File

@@ -268,3 +268,19 @@ const NavigationBar = ({ currentPage, onNavigate }) => {
};
export default NavigationBar;

View File

@@ -248,3 +248,19 @@
.menu-section::-webkit-scrollbar-thumb:hover {
background: #a1a1a1;
}

View File

@@ -172,3 +172,19 @@ const NavigationMenu = ({ user, currentPage, onPageChange }) => {
};
export default NavigationMenu;

View File

@@ -0,0 +1,482 @@
import React, { useState, useEffect } from 'react';
import { api } from '../api';
const PersonalizedDashboard = () => {
const [user, setUser] = useState(null);
const [dashboardData, setDashboardData] = useState(null);
const [loading, setLoading] = useState(true);
const [recentActivities, setRecentActivities] = useState([]);
useEffect(() => {
loadUserData();
loadDashboardData();
loadRecentActivities();
}, []);
const loadUserData = () => {
const userData = localStorage.getItem('user_data');
if (userData) {
setUser(JSON.parse(userData));
}
};
const loadDashboardData = async () => {
try {
// 실제 API에서 대시보드 데이터 로드
const response = await api.get('/dashboard/stats');
if (response.data && response.data.success) {
// API 데이터와 목 데이터를 병합 (quickActions 등 누락된 필드 보완)
const mockData = generateMockDataByRole();
const mergedData = {
...mockData,
...response.data.stats,
// quickActions가 없으면 목 데이터의 것을 사용
quickActions: response.data.stats.quickActions || mockData?.quickActions || []
};
setDashboardData(mergedData);
} else {
// API 실패 시 목 데이터 사용
console.log('대시보드 API 응답이 없어 목 데이터를 사용합니다.');
const mockData = generateMockDataByRole();
setDashboardData(mockData);
}
} catch (error) {
console.log('대시보드 API가 구현되지 않아 목 데이터를 사용합니다:', error.response?.status);
// 에러 시 목 데이터 사용
const mockData = generateMockDataByRole();
setDashboardData(mockData);
} finally {
setLoading(false);
}
};
const loadRecentActivities = async () => {
try {
// 실제 API에서 활동 이력 로드
const response = await api.get('/dashboard/activities?limit=5');
if (response.data.success && response.data.activities.length > 0) {
setRecentActivities(response.data.activities);
} else {
// API 실패 시 목 데이터 사용
const mockActivities = generateMockActivitiesByRole();
setRecentActivities(mockActivities);
}
} catch (error) {
console.log('활동 이력 API가 구현되지 않아 목 데이터를 사용합니다:', error.response?.status);
// 에러 시 목 데이터 사용
const mockActivities = generateMockActivitiesByRole();
setRecentActivities(mockActivities);
}
};
const generateMockDataByRole = () => {
if (!user) return null;
const baseData = {
admin: {
title: "시스템 관리자",
subtitle: "전체 시스템을 관리하고 모니터링합니다",
metrics: [
{ label: "전체 프로젝트 수", value: 45, icon: "📋", color: "#667eea" },
{ label: "활성 사용자 수", value: 12, icon: "👥", color: "#48bb78" },
{ label: "시스템 상태", value: "정상", icon: "🟢", color: "#38b2ac" },
{ label: "오늘 업로드", value: 8, icon: "📤", color: "#ed8936" }
],
quickActions: [
{ title: "사용자 관리", icon: "👤", path: "/admin/users", color: "#667eea" },
{ title: "시스템 설정", icon: "⚙️", path: "/admin/settings", color: "#48bb78" },
{ title: "백업 관리", icon: "💾", path: "/admin/backup", color: "#ed8936" },
{ title: "활동 로그", icon: "📊", path: "/admin/logs", color: "#9f7aea" }
]
},
manager: {
title: "프로젝트 매니저",
subtitle: "팀 프로젝트를 관리하고 진행상황을 모니터링합니다",
metrics: [
{ label: "담당 프로젝트", value: 8, icon: "📋", color: "#667eea" },
{ label: "팀 진행률", value: "87%", icon: "📈", color: "#48bb78" },
{ label: "승인 대기", value: 3, icon: "⏳", color: "#ed8936" },
{ label: "이번 주 완료", value: 5, icon: "✅", color: "#38b2ac" }
],
quickActions: [
{ title: "프로젝트 생성", icon: "", path: "/projects/new", color: "#667eea" },
{ title: "팀 관리", icon: "👥", path: "/team", color: "#48bb78" },
{ title: "진행 상황", icon: "📊", path: "/progress", color: "#38b2ac" },
{ title: "승인 처리", icon: "✅", path: "/approvals", color: "#ed8936" }
]
},
designer: {
title: "설계 담당자",
subtitle: "BOM 파일을 관리하고 자재를 분류합니다",
metrics: [
{ label: "내 BOM 파일", value: 15, icon: "📄", color: "#667eea" },
{ label: "분류 완료율", value: "92%", icon: "🎯", color: "#48bb78" },
{ label: "검증 대기", value: 7, icon: "⏳", color: "#ed8936" },
{ label: "이번 주 업로드", value: 12, icon: "📤", color: "#9f7aea" }
],
quickActions: [
{ title: "BOM 업로드", icon: "📤", path: "/upload", color: "#667eea" },
{ title: "자재 분류", icon: "🔧", path: "/materials", color: "#48bb78" },
{ title: "리비전 관리", icon: "🔄", path: "/revisions", color: "#38b2ac" },
{ title: "분류 검증", icon: "✅", path: "/verify", color: "#ed8936" }
]
},
purchaser: {
title: "구매 담당자",
subtitle: "구매 요청을 처리하고 발주를 관리합니다",
metrics: [
{ label: "구매 요청", value: 23, icon: "🛒", color: "#667eea" },
{ label: "발주 완료", value: 18, icon: "✅", color: "#48bb78" },
{ label: "입고 대기", value: 5, icon: "📦", color: "#ed8936" },
{ label: "이번 달 금액", value: "₩2.3M", icon: "💰", color: "#9f7aea" }
],
quickActions: [
{ title: "구매 확정", icon: "🛒", path: "/purchase", color: "#667eea" },
{ title: "발주 관리", icon: "📋", path: "/orders", color: "#48bb78" },
{ title: "공급업체", icon: "🏢", path: "/suppliers", color: "#38b2ac" },
{ title: "입고 처리", icon: "📦", path: "/receiving", color: "#ed8936" }
]
},
user: {
title: "일반 사용자",
subtitle: "할당된 업무를 수행하고 프로젝트에 참여합니다",
metrics: [
{ label: "내 업무", value: 6, icon: "📋", color: "#667eea" },
{ label: "완료율", value: "75%", icon: "📈", color: "#48bb78" },
{ label: "대기 중", value: 2, icon: "⏳", color: "#ed8936" },
{ label: "이번 주 활동", value: 12, icon: "🎯", color: "#9f7aea" }
],
quickActions: [
{ title: "내 업무", icon: "📋", path: "/my-tasks", color: "#667eea" },
{ title: "프로젝트 보기", icon: "👁️", path: "/projects", color: "#48bb78" },
{ title: "리포트 다운로드", icon: "📊", path: "/reports", color: "#38b2ac" },
{ title: "도움말", icon: "❓", path: "/help", color: "#9f7aea" }
]
}
};
return baseData[user.role] || baseData.user;
};
const generateMockActivitiesByRole = () => {
if (!user) return [];
const activities = {
admin: [
{ type: "system", message: "새 사용자 3명이 등록되었습니다", time: "30분 전", icon: "👥" },
{ type: "backup", message: "일일 백업이 완료되었습니다", time: "2시간 전", icon: "💾" },
{ type: "alert", message: "시스템 리소스 사용률 85%", time: "4시간 전", icon: "⚠️" },
{ type: "update", message: "데이터베이스 인덱스가 최적화되었습니다", time: "6시간 전", icon: "🔧" }
],
manager: [
{ type: "approval", message: "냉동기 프로젝트 구매 승인 완료", time: "1시간 전", icon: "✅" },
{ type: "meeting", message: "주간 팀 미팅 일정이 등록되었습니다", time: "3시간 전", icon: "📅" },
{ type: "progress", message: "BOG 시스템 프로젝트 90% 진행", time: "5시간 전", icon: "📈" },
{ type: "task", message: "김설계님에게 새 업무가 할당되었습니다", time: "1일 전", icon: "👤" }
],
designer: [
{ type: "upload", message: "다이아프램 펌프 BOM 파일을 업로드했습니다", time: "45분 전", icon: "📤" },
{ type: "classify", message: "스테인리스 파이프 127개 자재 분류 완료", time: "2시간 전", icon: "🔧" },
{ type: "revision", message: "드라이어 시스템 Rev.2 업데이트", time: "4시간 전", icon: "🔄" },
{ type: "verify", message: "볼트 분류 검증 5건 완료", time: "1일 전", icon: "✅" }
],
purchaser: [
{ type: "purchase", message: "스테인리스 파이프 구매 확정", time: "20분 전", icon: "🛒" },
{ type: "order", message: "ABC 공급업체에 발주서 전송", time: "1시간 전", icon: "📋" },
{ type: "receive", message: "밸브 15개 입고 처리 완료", time: "3시간 전", icon: "📦" },
{ type: "quote", message: "새 견적서 3건 접수", time: "5시간 전", icon: "💰" }
],
user: [
{ type: "task", message: "자재 검증 업무 2건 완료", time: "1시간 전", icon: "✅" },
{ type: "view", message: "냉동기 프로젝트 진행상황 확인", time: "3시간 전", icon: "👁️" },
{ type: "download", message: "월간 리포트 다운로드", time: "6시간 전", icon: "📊" },
{ type: "help", message: "도움말 페이지 방문", time: "1일 전", icon: "❓" }
]
};
return activities[user.role] || activities.user;
};
const handleQuickAction = (action) => {
// 실제 네비게이션 구현 (향후)
console.log(`네비게이션: ${action.path}`);
alert(`${action.title} 기능은 곧 구현될 예정입니다.`);
};
const handleLogout = () => {
localStorage.removeItem('access_token');
localStorage.removeItem('user_data');
window.location.reload();
};
if (loading || !user || !dashboardData) {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f7fafc'
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<div>대시보드를 불러오는 ...</div>
</div>
</div>
);
}
return (
<div style={{
minHeight: '100vh',
background: '#f7fafc',
fontFamily: 'Arial, sans-serif'
}}>
{/* 네비게이션 바 */}
<nav style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '16px 24px',
color: 'white',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '24px' }}>🚀</span>
<div>
<h1 style={{ margin: '0', fontSize: '20px', fontWeight: '700' }}>TK-MP System</h1>
<span style={{ fontSize: '12px', opacity: '0.9' }}>통합 프로젝트 관리</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '14px', fontWeight: '600' }}>{user.name || user.username}</div>
<div style={{ fontSize: '12px', opacity: '0.9' }}>
{dashboardData.title}
</div>
</div>
<button
onClick={handleLogout}
style={{
padding: '8px 16px',
background: 'rgba(255, 255, 255, 0.2)',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px'
}}
>
로그아웃
</button>
</div>
</nav>
{/* 메인 콘텐츠 */}
<main style={{ padding: '32px 24px' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{/* 개인별 맞춤 배너 */}
<div style={{
background: `linear-gradient(135deg, ${dashboardData.metrics[0].color}20 0%, ${dashboardData.metrics[1].color}20 100%)`,
borderRadius: '16px',
padding: '32px',
marginBottom: '32px',
border: `1px solid ${dashboardData.metrics[0].color}40`
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '16px' }}>
<div style={{ fontSize: '48px' }}>
{user.role === 'admin' ? '👑' :
user.role === 'manager' ? '👨‍💼' :
user.role === 'designer' ? '🎨' :
user.role === 'purchaser' ? '🛒' : '👤'}
</div>
<div>
<h2 style={{
margin: '0 0 8px 0',
fontSize: '28px',
fontWeight: '700',
color: '#2d3748'
}}>
안녕하세요, {user.name || user.username}! 👋
</h2>
<p style={{
margin: '0',
fontSize: '16px',
color: '#4a5568',
fontWeight: '500'
}}>
{dashboardData.subtitle}
</p>
</div>
</div>
</div>
{/* 핵심 지표 카드들 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: '24px',
marginBottom: '32px'
}}>
{(dashboardData.metrics || []).map((metric, index) => (
<div key={index} style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
border: '1px solid #e2e8f0',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
cursor: 'pointer'
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)';
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<div style={{
fontSize: '14px',
color: '#718096',
marginBottom: '8px',
fontWeight: '500'
}}>
{metric.label}
</div>
<div style={{
fontSize: '32px',
fontWeight: '700',
color: metric.color
}}>
{metric.value}
</div>
</div>
<div style={{
fontSize: '32px',
opacity: 0.8
}}>
{metric.icon}
</div>
</div>
</div>
))}
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
gap: '24px'
}}>
{/* 빠른 작업 */}
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
border: '1px solid #e2e8f0'
}}>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 20px 0'
}}>
빠른 작업
</h3>
<div style={{ display: 'grid', gap: '12px' }}>
{(dashboardData.quickActions || []).map((action, index) => (
<button
key={index}
onClick={() => handleQuickAction(action)}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 16px',
background: 'transparent',
border: '1px solid #e2e8f0',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s ease',
fontSize: '14px',
color: '#4a5568',
textAlign: 'left'
}}
onMouseEnter={(e) => {
e.target.style.background = `${action.color}10`;
e.target.style.borderColor = action.color;
}}
onMouseLeave={(e) => {
e.target.style.background = 'transparent';
e.target.style.borderColor = '#e2e8f0';
}}
>
<span style={{ fontSize: '16px' }}>{action.icon}</span>
<span>{action.title}</span>
</button>
))}
</div>
</div>
{/* 최근 활동 */}
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
border: '1px solid #e2e8f0'
}}>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 20px 0'
}}>
📈 최근 활동
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{recentActivities.map((activity, index) => (
<div key={index} style={{
display: 'flex',
alignItems: 'flex-start',
gap: '12px',
padding: '12px',
borderRadius: '8px',
background: '#f7fafc',
border: '1px solid #e2e8f0'
}}>
<span style={{ fontSize: '16px' }}>
{activity.icon}
</span>
<div style={{ flex: 1 }}>
<div style={{
fontSize: '14px',
color: '#2d3748',
marginBottom: '4px'
}}>
{activity.message}
</div>
<div style={{
fontSize: '12px',
color: '#718096'
}}>
{activity.time}
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</main>
</div>
);
};
export default PersonalizedDashboard;

View File

@@ -0,0 +1,319 @@
import React, { useState, useEffect } from 'react';
import { api } from '../api';
const ProjectSelector = ({ onProjectSelect, selectedProject }) => {
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [showDropdown, setShowDropdown] = useState(false);
useEffect(() => {
loadProjects();
}, []);
const loadProjects = async () => {
try {
const response = await api.get('/jobs/');
console.log('프로젝트 API 응답:', response.data);
// API 응답 구조에 맞게 처리
let projectsData = [];
if (response.data && response.data.success && Array.isArray(response.data.jobs)) {
// 실제 API 데이터를 프론트엔드 형식에 맞게 변환
projectsData = response.data.jobs.map(job => ({
job_no: job.job_no,
project_name: job.project_name || job.job_name,
status: job.status === '진행중' ? 'active' : 'completed',
progress: job.status === '진행중' ? 75 : 100, // 임시 진행률
client_name: job.client_name,
project_site: job.project_site,
delivery_date: job.delivery_date
}));
}
// 데이터가 없으면 목 데이터 사용
if (projectsData.length === 0) {
projectsData = [
{ job_no: 'TK-2024-001', project_name: '냉동기 시스템', status: 'active', progress: 75 },
{ job_no: 'TK-2024-002', project_name: 'BOG 처리 시스템', status: 'active', progress: 45 },
{ job_no: 'TK-2024-003', project_name: '다이아프램 펌프', status: 'active', progress: 90 },
{ job_no: 'TK-2024-004', project_name: '드라이어 시스템', status: 'completed', progress: 100 },
{ job_no: 'TK-2024-005', project_name: '열교환기 시스템', status: 'active', progress: 30 }
];
}
setProjects(projectsData);
} catch (error) {
console.error('프로젝트 목록 로딩 실패:', error);
// 목 데이터 사용
const mockProjects = [
{ job_no: 'TK-2024-001', project_name: '냉동기 시스템', status: 'active', progress: 75 },
{ job_no: 'TK-2024-002', project_name: 'BOG 처리 시스템', status: 'active', progress: 45 },
{ job_no: 'TK-2024-003', project_name: '다이아프램 펌프', status: 'active', progress: 90 },
{ job_no: 'TK-2024-004', project_name: '드라이어 시스템', status: 'completed', progress: 100 },
{ job_no: 'TK-2024-005', project_name: '열교환기 시스템', status: 'active', progress: 30 }
];
setProjects(mockProjects);
} finally {
setLoading(false);
}
};
const filteredProjects = projects.filter(project =>
project.project_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.job_no.toLowerCase().includes(searchTerm.toLowerCase())
);
const getStatusColor = (status) => {
const colors = {
'active': '#48bb78',
'completed': '#38b2ac',
'on_hold': '#ed8936',
'cancelled': '#e53e3e'
};
return colors[status] || '#718096';
};
const getStatusText = (status) => {
const texts = {
'active': '진행중',
'completed': '완료',
'on_hold': '보류',
'cancelled': '취소'
};
return texts[status] || '알 수 없음';
};
if (loading) {
return (
<div style={{
padding: '20px',
textAlign: 'center',
color: '#666'
}}>
프로젝트 목록을 불러오는 ...
</div>
);
}
return (
<div style={{ position: 'relative', width: '100%' }}>
{/* 선택된 프로젝트 표시 또는 선택 버튼 */}
<div
onClick={() => setShowDropdown(!showDropdown)}
style={{
padding: '16px 20px',
background: selectedProject ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'white',
color: selectedProject ? 'white' : '#2d3748',
border: selectedProject ? 'none' : '2px dashed #cbd5e0',
borderRadius: '12px',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: selectedProject ? '0 4px 12px rgba(102, 126, 234, 0.3)' : '0 2px 4px rgba(0,0,0,0.1)'
}}
onMouseEnter={(e) => {
if (!selectedProject) {
e.target.style.borderColor = '#667eea';
e.target.style.backgroundColor = '#f7fafc';
}
}}
onMouseLeave={(e) => {
if (!selectedProject) {
e.target.style.borderColor = '#cbd5e0';
e.target.style.backgroundColor = 'white';
}
}}
>
<div>
{selectedProject ? (
<div>
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '4px' }}>
{selectedProject.project_name}
</div>
<div style={{ fontSize: '14px', opacity: '0.9' }}>
{selectedProject.job_no} {getStatusText(selectedProject.status)}
</div>
</div>
) : (
<div>
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '4px' }}>
🎯 프로젝트를 선택하세요
</div>
<div style={{ fontSize: '14px', color: '#718096' }}>
작업할 프로젝트를 선택하면 관련 업무를 시작할 있습니다
</div>
</div>
)}
</div>
<div style={{ fontSize: '20px' }}>
{showDropdown ? '🔼' : '🔽'}
</div>
</div>
{/* 드롭다운 메뉴 */}
{showDropdown && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
marginTop: '8px',
background: 'white',
borderRadius: '12px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.15)',
border: '1px solid #e2e8f0',
zIndex: 1000,
maxHeight: '400px',
overflow: 'hidden'
}}>
{/* 검색 입력 */}
<div style={{ padding: '16px', borderBottom: '1px solid #e2e8f0' }}>
<input
type="text"
placeholder="프로젝트 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #cbd5e0',
borderRadius: '6px',
fontSize: '14px',
outline: 'none'
}}
onFocus={(e) => e.target.style.borderColor = '#667eea'}
onBlur={(e) => e.target.style.borderColor = '#cbd5e0'}
/>
</div>
{/* 프로젝트 목록 */}
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
{filteredProjects.length === 0 ? (
<div style={{
padding: '20px',
textAlign: 'center',
color: '#718096'
}}>
검색 결과가 없습니다
</div>
) : (
filteredProjects.map((project) => (
<div
key={project.job_no}
onClick={() => {
onProjectSelect(project);
setShowDropdown(false);
setSearchTerm('');
}}
style={{
padding: '16px 20px',
cursor: 'pointer',
borderBottom: '1px solid #f7fafc',
transition: 'background-color 0.2s ease'
}}
onMouseEnter={(e) => {
e.target.style.backgroundColor = '#f7fafc';
}}
onMouseLeave={(e) => {
e.target.style.backgroundColor = 'transparent';
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div style={{ flex: 1 }}>
<div style={{
fontSize: '16px',
fontWeight: '600',
color: '#2d3748',
marginBottom: '4px'
}}>
{project.project_name}
</div>
<div style={{
fontSize: '14px',
color: '#718096',
marginBottom: '8px'
}}>
{project.job_no}
</div>
{/* 진행률 바 */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<div style={{
flex: 1,
height: '4px',
backgroundColor: '#e2e8f0',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
width: `${project.progress || 0}%`,
height: '100%',
backgroundColor: getStatusColor(project.status),
transition: 'width 0.3s ease'
}} />
</div>
<div style={{
fontSize: '12px',
color: '#718096',
minWidth: '35px'
}}>
{project.progress || 0}%
</div>
</div>
</div>
<div style={{
marginLeft: '16px',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end'
}}>
<span style={{
padding: '4px 8px',
backgroundColor: `${getStatusColor(project.status)}20`,
color: getStatusColor(project.status),
borderRadius: '12px',
fontSize: '12px',
fontWeight: '500'
}}>
{getStatusText(project.status)}
</span>
</div>
</div>
</div>
))
)}
</div>
</div>
)}
{/* 드롭다운 외부 클릭 시 닫기 */}
{showDropdown && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 999
}}
onClick={() => setShowDropdown(false)}
/>
)}
</div>
);
};
export default ProjectSelector;

View File

@@ -80,3 +80,19 @@ const RevisionUploadDialog = ({
};
export default RevisionUploadDialog;

View File

@@ -299,3 +299,19 @@ const SimpleFileUpload = ({ selectedProject, onUploadComplete }) => {
};
export default SimpleFileUpload;

View File

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

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

View File

@@ -262,3 +262,19 @@ const DashboardPage = ({ user }) => {
};
export default DashboardPage;

View File

@@ -217,3 +217,19 @@
border-color: #667eea;
}
}

View File

@@ -114,3 +114,19 @@ const LoginPage = () => {
};
export default LoginPage;

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

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

View File

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

View File

@@ -386,3 +386,19 @@ const ProjectsPage = ({ user }) => {
};
export default ProjectsPage;

View File

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

View File

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

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

View File

@@ -428,3 +428,19 @@
width: 100%;
}
}

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -5,7 +5,7 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
port: 13000,
host: true,
open: true
},