feat: SWG 가스켓 전체 구성 정보 표시 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- H/F/I/O SS304/GRAPHITE/CS/CS 패턴에서 4개 구성요소 모두 표시 - 기존 SS304 + GRAPHITE → SS304/GRAPHITE/CS/CS로 완전한 구성 표시 - 외부링/필러/내부링/추가구성 모든 정보 포함 - 구매수량 계산 모달에서 정확한 재질 정보 확인 가능
This commit is contained in:
@@ -1,42 +1,249 @@
|
||||
/* 전역 스타일 리셋 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #2d3748;
|
||||
background-color: #f7fafc;
|
||||
}
|
||||
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 로딩 스피너 */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: #f7fafc;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.loading-spinner-large {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #e2e8f0;
|
||||
border-top: 4px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-top: 2px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 접근 거부 페이지 */
|
||||
.access-denied-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: #f7fafc;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.access-denied-content {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
background: white;
|
||||
padding: 48px 32px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
.access-denied-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
.access-denied-content h2 {
|
||||
color: #2d3748;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.access-denied-content p {
|
||||
color: #718096;
|
||||
font-size: 16px;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.permission-info,
|
||||
.role-info {
|
||||
background: #f7fafc;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.permission-info code,
|
||||
.role-info code {
|
||||
background: #e2e8f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 13px;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
background: #edf2f7;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.user-info p {
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-info strong {
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
/* 유틸리티 클래스 */
|
||||
.text-center { text-align: center; }
|
||||
.text-left { text-align: left; }
|
||||
.text-right { text-align: right; }
|
||||
|
||||
.mb-0 { margin-bottom: 0; }
|
||||
.mb-1 { margin-bottom: 8px; }
|
||||
.mb-2 { margin-bottom: 16px; }
|
||||
.mb-3 { margin-bottom: 24px; }
|
||||
.mb-4 { margin-bottom: 32px; }
|
||||
|
||||
.mt-0 { margin-top: 0; }
|
||||
.mt-1 { margin-top: 8px; }
|
||||
.mt-2 { margin-top: 16px; }
|
||||
.mt-3 { margin-top: 24px; }
|
||||
.mt-4 { margin-top: 32px; }
|
||||
|
||||
.p-0 { padding: 0; }
|
||||
.p-1 { padding: 8px; }
|
||||
.p-2 { padding: 16px; }
|
||||
.p-3 { padding: 24px; }
|
||||
.p-4 { padding: 32px; }
|
||||
|
||||
/* 반응형 유틸리티 */
|
||||
.hidden-mobile {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hidden-desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hidden-mobile {
|
||||
display: none;
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
|
||||
.hidden-desktop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
/* 스크롤바 스타일링 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a0aec0;
|
||||
}
|
||||
|
||||
/* 포커스 스타일 */
|
||||
*:focus {
|
||||
outline: 2px solid #667eea;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:focus,
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid #667eea;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 선택 스타일 */
|
||||
::selection {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
color: #2d3748;
|
||||
}
|
||||
@@ -1,26 +1,204 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import ProjectSelectionPage from './pages/ProjectSelectionPage';
|
||||
import MaterialsPage from './pages/MaterialsPage';
|
||||
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 PurchaseConfirmationPage from './pages/PurchaseConfirmationPage';
|
||||
import SimpleMaterialsPage from './pages/SimpleMaterialsPage';
|
||||
import MaterialComparisonPage from './pages/MaterialComparisonPage';
|
||||
import RevisionPurchasePage from './pages/RevisionPurchasePage';
|
||||
import JobSelectionPage from './pages/JobSelectionPage';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [user, setUser] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState('dashboard');
|
||||
const [pageParams, setPageParams] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
// 저장된 토큰 확인
|
||||
const token = localStorage.getItem('access_token');
|
||||
const userData = localStorage.getItem('user_data');
|
||||
|
||||
if (token && userData) {
|
||||
setIsAuthenticated(true);
|
||||
setUser(JSON.parse(userData));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
// 로그인 성공 시 호출될 함수
|
||||
const handleLoginSuccess = () => {
|
||||
const userData = localStorage.getItem('user_data');
|
||||
if (userData) {
|
||||
setUser(JSON.parse(userData));
|
||||
}
|
||||
setIsAuthenticated(true);
|
||||
};
|
||||
|
||||
// 로그아웃 함수
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('user_data');
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
setCurrentPage('dashboard');
|
||||
};
|
||||
|
||||
// 페이지 네비게이션 함수
|
||||
const navigateToPage = (page, params = {}) => {
|
||||
setCurrentPage(page);
|
||||
setPageParams(params);
|
||||
};
|
||||
|
||||
// 페이지 렌더링 함수
|
||||
const renderCurrentPage = () => {
|
||||
switch (currentPage) {
|
||||
case 'dashboard':
|
||||
return <DashboardPage user={user} />;
|
||||
case 'projects':
|
||||
return <ProjectsPage user={user} />;
|
||||
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}
|
||||
/>;
|
||||
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>;
|
||||
default:
|
||||
return <DashboardPage user={user} />;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f7fafc'
|
||||
}}>
|
||||
<div>로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <SimpleLogin onLoginSuccess={handleLoginSuccess} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<ProjectSelectionPage />} />
|
||||
{/* BOM 관리는 /bom-status로 통일 */}
|
||||
<Route path="/bom-manager" element={<Navigate to="/bom-status" replace />} />
|
||||
<Route path="/bom-status" element={<BOMStatusPage />} />
|
||||
<Route path="/materials" element={<MaterialsPage />} />
|
||||
<Route path="/purchase-confirmation" element={<PurchaseConfirmationPage />} />
|
||||
<Route path="/material-comparison" element={<MaterialComparisonPage />} />
|
||||
<Route path="/revision-purchase" element={<RevisionPurchasePage />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
220
frontend/src/SimpleDashboard.jsx
Normal file
220
frontend/src/SimpleDashboard.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const SimpleDashboard = () => {
|
||||
const [user, setUser] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 저장된 사용자 정보 불러오기
|
||||
const userData = localStorage.getItem('user_data');
|
||||
if (userData) {
|
||||
setUser(JSON.parse(userData));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('user_data');
|
||||
window.location.reload(); // 페이지 새로고침으로 로그인 페이지로 돌아가기
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f7fafc'
|
||||
}}>
|
||||
<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}</div>
|
||||
<div style={{ fontSize: '12px', opacity: '0.9' }}>
|
||||
{user.role} · {user.access_level}
|
||||
</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: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<h2 style={{
|
||||
color: '#2d3748',
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
margin: '0 0 16px 0'
|
||||
}}>
|
||||
환영합니다, {user.name}님! 🎉
|
||||
</h2>
|
||||
<p style={{ color: '#718096', fontSize: '16px', margin: '0' }}>
|
||||
TK-MP 통합 프로젝트 관리 시스템에 성공적으로 로그인하셨습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 사용자 정보 카드 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<h3 style={{
|
||||
color: '#2d3748',
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
margin: '0 0 24px 0'
|
||||
}}>
|
||||
👤 사용자 정보
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '16px' }}>
|
||||
<div>
|
||||
<strong style={{ color: '#4a5568' }}>사용자명:</strong>
|
||||
<div style={{ color: '#2d3748', marginTop: '4px' }}>{user.username}</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong style={{ color: '#4a5568' }}>이메일:</strong>
|
||||
<div style={{ color: '#2d3748', marginTop: '4px' }}>{user.email}</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong style={{ color: '#4a5568' }}>역할:</strong>
|
||||
<div style={{ color: '#2d3748', marginTop: '4px' }}>{user.role}</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong style={{ color: '#4a5568' }}>접근 레벨:</strong>
|
||||
<div style={{ color: '#2d3748', marginTop: '4px' }}>{user.access_level}</div>
|
||||
</div>
|
||||
{user.department && (
|
||||
<div>
|
||||
<strong style={{ color: '#4a5568' }}>부서:</strong>
|
||||
<div style={{ color: '#2d3748', marginTop: '4px' }}>{user.department}</div>
|
||||
</div>
|
||||
)}
|
||||
{user.position && (
|
||||
<div>
|
||||
<strong style={{ color: '#4a5568' }}>직책:</strong>
|
||||
<div style={{ color: '#2d3748', marginTop: '4px' }}>{user.position}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 권한 정보 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)'
|
||||
}}>
|
||||
<h3 style={{
|
||||
color: '#2d3748',
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
margin: '0 0 24px 0'
|
||||
}}>
|
||||
🔐 보유 권한
|
||||
</h3>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px'
|
||||
}}>
|
||||
{user.permissions && user.permissions.length > 0 ? (
|
||||
user.permissions.map(permission => (
|
||||
<span
|
||||
key={permission}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#e2e8f0',
|
||||
color: '#4a5568',
|
||||
borderRadius: '20px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{permission}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: '#718096' }}>권한 정보가 없습니다.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 다음 단계 안내 */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
color: 'white',
|
||||
marginTop: '32px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '20px', fontWeight: '600' }}>
|
||||
🚀 다음 단계
|
||||
</h3>
|
||||
<p style={{ margin: '0', fontSize: '16px', opacity: '0.9' }}>
|
||||
이제 복잡한 인증 시스템과 네비게이션을 단계적으로 추가할 준비가 되었습니다!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleDashboard;
|
||||
212
frontend/src/SimpleLogin.jsx
Normal file
212
frontend/src/SimpleLogin.jsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React, { useState } from 'react';
|
||||
import api from './api';
|
||||
|
||||
const SimpleLogin = ({ onLoginSuccess }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
if (error) setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.username || !formData.password) {
|
||||
setError('사용자명과 비밀번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await api.post('/auth/login', formData);
|
||||
const data = response.data;
|
||||
|
||||
if (data.success) {
|
||||
// 토큰과 사용자 정보 저장
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
localStorage.setItem('user_data', JSON.stringify(data.user));
|
||||
|
||||
setSuccess('로그인 성공! 대시보드로 이동합니다...');
|
||||
|
||||
// 잠깐 성공 메시지 보여준 후 대시보드로 이동
|
||||
setTimeout(() => {
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess();
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
setError(data.error?.message || '로그인에 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
setError(err.response?.data?.message || '서버 연결에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: '20px',
|
||||
fontFamily: 'Arial, sans-serif'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.1)',
|
||||
padding: '40px',
|
||||
width: '100%',
|
||||
maxWidth: '400px'
|
||||
}}>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
marginTop: '8px'
|
||||
}}
|
||||
>
|
||||
{isLoading ? '로그인 중...' : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: '32px', textAlign: 'center' }}>
|
||||
<p style={{ color: '#718096', fontSize: '14px', margin: '0 0 16px 0' }}>
|
||||
테스트 계정: admin / admin123 또는 testuser / test123
|
||||
</p>
|
||||
<div>
|
||||
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||
TK-MP Project Management System v2.0
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleLogin;
|
||||
43
frontend/src/TestApp.jsx
Normal file
43
frontend/src/TestApp.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
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;
|
||||
@@ -173,4 +173,7 @@ export function getMaterialPurchaseStatus(jobNo, revision = null, status = null)
|
||||
return api.get('/materials/purchase-status', {
|
||||
params: { job_no: jobNo, revision, status }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Default export for convenience
|
||||
export default api;
|
||||
157
frontend/src/components/BOMFileTable.jsx
Normal file
157
frontend/src/components/BOMFileTable.jsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React from 'react';
|
||||
|
||||
const BOMFileTable = ({
|
||||
files,
|
||||
loading,
|
||||
groupFilesByBOM,
|
||||
handleViewMaterials,
|
||||
openRevisionDialog,
|
||||
handleDelete
|
||||
}) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
로딩 중...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
background: '#bee3f8',
|
||||
border: '1px solid #63b3ed',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
color: '#2c5282'
|
||||
}}>
|
||||
업로드된 BOM이 없습니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f7fafc' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>BOM 이름</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>파일명</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>리비전</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>자재 수</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>업로드 일시</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(groupFilesByBOM()).map(([bomKey, bomFiles]) => (
|
||||
bomFiles.map((file, index) => (
|
||||
<tr
|
||||
key={file.id}
|
||||
style={{
|
||||
backgroundColor: index === 0 ? 'rgba(25, 118, 210, 0.08)' : 'rgba(0, 0, 0, 0.02)'
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<div style={{
|
||||
fontWeight: index === 0 ? 'bold' : 'normal',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
{file.bom_name || bomKey}
|
||||
</div>
|
||||
{index === 0 && bomFiles.length > 1 && (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#4299e1',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
최신 리비전 (총 {bomFiles.length}개)
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0', fontSize: '14px' }}>
|
||||
{file.original_filename || file.filename}
|
||||
</td>
|
||||
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<span style={{
|
||||
background: index === 0 ? '#4299e1' : '#e2e8f0',
|
||||
color: index === 0 ? 'white' : '#4a5568',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{file.revision || 'Rev.0'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0', fontSize: '14px' }}>
|
||||
{file.parsed_count || file.material_count || 0}개
|
||||
</td>
|
||||
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0', fontSize: '14px' }}>
|
||||
{file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => handleViewMaterials(file)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#48bb78',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
🧮 구매수량 계산
|
||||
</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={() => handleDelete(file.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#f56565',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMFileTable;
|
||||
118
frontend/src/components/BOMFileUpload.jsx
Normal file
118
frontend/src/components/BOMFileUpload.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
|
||||
const BOMFileUpload = ({
|
||||
bomName,
|
||||
setBomName,
|
||||
selectedFile,
|
||||
setSelectedFile,
|
||||
uploading,
|
||||
handleUpload,
|
||||
error
|
||||
}) => {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: '24px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 16px 0'
|
||||
}}>
|
||||
새 BOM 업로드
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
BOM 이름
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bomName}
|
||||
onChange={(e) => setBomName(e.target.value)}
|
||||
placeholder="예: PIPING_BOM_A구역"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
<p style={{
|
||||
fontSize: '12px',
|
||||
color: '#718096',
|
||||
margin: '4px 0 0 0'
|
||||
}}>
|
||||
동일한 BOM 이름으로 재업로드 시 리비전이 자동 증가합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => setSelectedFile(e.target.files[0])}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || !bomName.trim() || uploading}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: (!selectedFile || !bomName.trim() || uploading) ? '#e2e8f0' : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: (!selectedFile || !bomName.trim() || uploading) ? '#a0aec0' : 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: (!selectedFile || !bomName.trim() || uploading) ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '업로드'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedFile && (
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096',
|
||||
margin: '0'
|
||||
}}>
|
||||
선택된 파일: {selectedFile.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fed7d7',
|
||||
border: '1px solid #fc8181',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
marginTop: '16px',
|
||||
color: '#c53030'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMFileUpload;
|
||||
537
frontend/src/components/NavigationBar.css
Normal file
537
frontend/src/components/NavigationBar.css
Normal file
@@ -0,0 +1,537 @@
|
||||
.navigation-bar {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
/* 브랜드 로고 */
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
font-size: 32px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-text span {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
display: block;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 모바일 메뉴 토글 */
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle span {
|
||||
width: 24px;
|
||||
height: 3px;
|
||||
background: white;
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 메인 메뉴 */
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menu-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.menu-item.active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.menu-item.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20px;
|
||||
height: 3px;
|
||||
background: white;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* 사용자 메뉴 */
|
||||
.user-menu-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-menu-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.user-menu-trigger:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 11px;
|
||||
opacity: 0.9;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
font-size: 10px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.user-menu-trigger:hover .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 사용자 드롭다운 */
|
||||
.user-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 320px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
animation: dropdownSlide 0.3s ease-out;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@keyframes dropdownSlide {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.user-dropdown-header {
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.user-avatar-large {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-details .user-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #2d3748;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-username {
|
||||
font-size: 14px;
|
||||
color: #718096;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 13px;
|
||||
color: #4a5568;
|
||||
margin-bottom: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.role-badge,
|
||||
.access-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.role-badge { background: #bee3f8; color: #2b6cb0; }
|
||||
.access-badge { background: #c6f6d5; color: #2f855a; }
|
||||
|
||||
.user-department {
|
||||
font-size: 12px;
|
||||
color: #718096;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.user-dropdown-menu {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: #f7fafc;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.dropdown-item.logout-item {
|
||||
color: #e53e3e;
|
||||
}
|
||||
|
||||
.dropdown-item.logout-item:hover {
|
||||
background: #fed7d7;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background: #e2e8f0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.user-dropdown-footer {
|
||||
padding: 16px 24px;
|
||||
background: #f7fafc;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.permissions-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.permissions-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #718096;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.permissions-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.permission-tag {
|
||||
padding: 2px 6px;
|
||||
background: #e2e8f0;
|
||||
color: #4a5568;
|
||||
font-size: 10px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.permission-more {
|
||||
padding: 2px 6px;
|
||||
background: #cbd5e0;
|
||||
color: #2d3748;
|
||||
font-size: 10px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 모바일 오버레이 */
|
||||
.mobile-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 1024px) {
|
||||
.menu-items {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nav-container {
|
||||
padding: 0 16px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.brand-text span {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-menu.mobile-open {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.mobile-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-items {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
padding: 16px;
|
||||
color: #2d3748;
|
||||
border-radius: 12px;
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: #edf2f7;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.menu-item.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.user-menu-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-menu-trigger {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
background: #f7fafc;
|
||||
color: #2d3748;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
position: static;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
box-shadow: none;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.nav-container {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
width: calc(100vw - 24px);
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
270
frontend/src/components/NavigationBar.jsx
Normal file
270
frontend/src/components/NavigationBar.jsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import './NavigationBar.css';
|
||||
|
||||
const NavigationBar = ({ currentPage, onNavigate }) => {
|
||||
const { user, logout, hasPermission, isAdmin, isManager } = useAuth();
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
// 메뉴 항목 정의 (권한별)
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: '대시보드',
|
||||
icon: '📊',
|
||||
path: '/dashboard',
|
||||
permission: null, // 모든 사용자 접근 가능
|
||||
description: '전체 현황 보기'
|
||||
},
|
||||
{
|
||||
id: 'projects',
|
||||
label: '프로젝트 관리',
|
||||
icon: '📋',
|
||||
path: '/projects',
|
||||
permission: 'project.view',
|
||||
description: '프로젝트 등록 및 관리'
|
||||
},
|
||||
{
|
||||
id: 'bom',
|
||||
label: 'BOM 관리',
|
||||
icon: '📄',
|
||||
path: '/bom',
|
||||
permission: 'bom.view',
|
||||
description: 'BOM 파일 업로드 및 분석'
|
||||
},
|
||||
{
|
||||
id: 'materials',
|
||||
label: '자재 관리',
|
||||
icon: '🔧',
|
||||
path: '/materials',
|
||||
permission: 'bom.view',
|
||||
description: '자재 목록 및 비교'
|
||||
},
|
||||
{
|
||||
id: 'purchase',
|
||||
label: '구매 관리',
|
||||
icon: '💰',
|
||||
path: '/purchase',
|
||||
permission: 'project.view',
|
||||
description: '구매 확인 및 관리'
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
label: '파일 관리',
|
||||
icon: '📁',
|
||||
path: '/files',
|
||||
permission: 'file.upload',
|
||||
description: '파일 업로드 및 관리'
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: '사용자 관리',
|
||||
icon: '👥',
|
||||
path: '/users',
|
||||
permission: 'user.view',
|
||||
description: '사용자 계정 관리',
|
||||
adminOnly: true
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
label: '시스템 설정',
|
||||
icon: '⚙️',
|
||||
path: '/system',
|
||||
permission: 'system.admin',
|
||||
description: '시스템 환경 설정',
|
||||
adminOnly: true
|
||||
}
|
||||
];
|
||||
|
||||
// 사용자가 접근 가능한 메뉴만 필터링
|
||||
const accessibleMenuItems = menuItems.filter(item => {
|
||||
// 관리자 전용 메뉴 체크
|
||||
if (item.adminOnly && !isAdmin() && !isManager()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 권한 체크
|
||||
if (item.permission && !hasPermission(item.permission)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
setShowUserMenu(false);
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuClick = (item) => {
|
||||
onNavigate(item.id);
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const getRoleDisplayName = (role) => {
|
||||
const roleMap = {
|
||||
'admin': '관리자',
|
||||
'system': '시스템',
|
||||
'leader': '팀장',
|
||||
'support': '지원',
|
||||
'user': '사용자'
|
||||
};
|
||||
return roleMap[role] || role;
|
||||
};
|
||||
|
||||
const getAccessLevelDisplayName = (level) => {
|
||||
const levelMap = {
|
||||
'manager': '관리자',
|
||||
'leader': '팀장',
|
||||
'worker': '작업자',
|
||||
'viewer': '조회자'
|
||||
};
|
||||
return levelMap[level] || level;
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="navigation-bar">
|
||||
<div className="nav-container">
|
||||
{/* 로고 및 브랜드 */}
|
||||
<div className="nav-brand">
|
||||
<div className="brand-logo">🚀</div>
|
||||
<div className="brand-text">
|
||||
<h1>TK-MP System</h1>
|
||||
<span>통합 프로젝트 관리</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일 메뉴 토글 */}
|
||||
<button
|
||||
className="mobile-menu-toggle"
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
|
||||
{/* 메인 메뉴 */}
|
||||
<div className={`nav-menu ${isMobileMenuOpen ? 'mobile-open' : ''}`}>
|
||||
<div className="menu-items">
|
||||
{accessibleMenuItems.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`menu-item ${currentPage === item.id ? 'active' : ''}`}
|
||||
onClick={() => handleMenuClick(item)}
|
||||
title={item.description}
|
||||
>
|
||||
<span className="menu-icon">{item.icon}</span>
|
||||
<span className="menu-label">{item.label}</span>
|
||||
{item.adminOnly && (
|
||||
<span className="admin-badge">관리자</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 사용자 메뉴 */}
|
||||
<div className="user-menu-container">
|
||||
<button
|
||||
className="user-menu-trigger"
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
>
|
||||
<div className="user-avatar">
|
||||
{user?.name?.charAt(0) || '👤'}
|
||||
</div>
|
||||
<div className="user-info">
|
||||
<span className="user-name">{user?.name}</span>
|
||||
<span className="user-role">
|
||||
{getRoleDisplayName(user?.role)} · {getAccessLevelDisplayName(user?.access_level)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="dropdown-arrow">▼</span>
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div className="user-dropdown">
|
||||
<div className="user-dropdown-header">
|
||||
<div className="user-avatar-large">
|
||||
{user?.name?.charAt(0) || '👤'}
|
||||
</div>
|
||||
<div className="user-details">
|
||||
<div className="user-name">{user?.name}</div>
|
||||
<div className="user-username">@{user?.username}</div>
|
||||
<div className="user-email">{user?.email}</div>
|
||||
<div className="user-meta">
|
||||
<span className="role-badge role-{user?.role}">
|
||||
{getRoleDisplayName(user?.role)}
|
||||
</span>
|
||||
<span className="access-badge access-{user?.access_level}">
|
||||
{getAccessLevelDisplayName(user?.access_level)}
|
||||
</span>
|
||||
</div>
|
||||
{user?.department && (
|
||||
<div className="user-department">{user.department}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-dropdown-menu">
|
||||
<button className="dropdown-item">
|
||||
<span className="item-icon">👤</span>
|
||||
프로필 설정
|
||||
</button>
|
||||
<button className="dropdown-item">
|
||||
<span className="item-icon">🔐</span>
|
||||
비밀번호 변경
|
||||
</button>
|
||||
<button className="dropdown-item">
|
||||
<span className="item-icon">🔔</span>
|
||||
알림 설정
|
||||
</button>
|
||||
<div className="dropdown-divider"></div>
|
||||
<button
|
||||
className="dropdown-item logout-item"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<span className="item-icon">🚪</span>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="user-dropdown-footer">
|
||||
<div className="permissions-info">
|
||||
<span className="permissions-label">권한:</span>
|
||||
<div className="permissions-list">
|
||||
{user?.permissions?.slice(0, 3).map(permission => (
|
||||
<span key={permission} className="permission-tag">
|
||||
{permission}
|
||||
</span>
|
||||
))}
|
||||
{user?.permissions?.length > 3 && (
|
||||
<span className="permission-more">
|
||||
+{user.permissions.length - 3}개 더
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일 오버레이 */}
|
||||
{isMobileMenuOpen && (
|
||||
<div
|
||||
className="mobile-overlay"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationBar;
|
||||
250
frontend/src/components/NavigationMenu.css
Normal file
250
frontend/src/components/NavigationMenu.css
Normal file
@@ -0,0 +1,250 @@
|
||||
/* 네비게이션 메뉴 스타일 */
|
||||
.navigation-menu {
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* 모바일 햄버거 버튼 */
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle span {
|
||||
width: 24px;
|
||||
height: 3px;
|
||||
background: #4a5568;
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 메뉴 오버레이 (모바일) */
|
||||
.menu-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
/* 사이드바 */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* 사이드바 헤더 */
|
||||
.sidebar-header {
|
||||
padding: 24px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.logo-text h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.logo-text span {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 메뉴 섹션 */
|
||||
.menu-section {
|
||||
flex: 1;
|
||||
padding: 20px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.menu-section-title {
|
||||
padding: 0 20px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #718096;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.menu-button:hover {
|
||||
background: #f7fafc;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.menu-button.active {
|
||||
background: #edf2f7;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-button.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: #667eea;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 18px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.active-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #667eea;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* 사이드바 푸터 */
|
||||
.sidebar-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 12px;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.mobile-menu-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.menu-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 데스크톱에서 사이드바가 있을 때 메인 콘텐츠 여백 */
|
||||
@media (min-width: 769px) {
|
||||
.main-content-with-sidebar {
|
||||
margin-left: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 스크롤바 스타일링 */
|
||||
.menu-section::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.menu-section::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.menu-section::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.menu-section::-webkit-scrollbar-thumb:hover {
|
||||
background: #a1a1a1;
|
||||
}
|
||||
174
frontend/src/components/NavigationMenu.jsx
Normal file
174
frontend/src/components/NavigationMenu.jsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React, { useState } from 'react';
|
||||
import './NavigationMenu.css';
|
||||
|
||||
const NavigationMenu = ({ user, currentPage, onPageChange }) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
// 권한별 메뉴 정의
|
||||
const getMenuItems = () => {
|
||||
const baseMenus = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
title: '대시보드',
|
||||
icon: '🏠',
|
||||
description: '시스템 현황 및 개요',
|
||||
requiredPermission: null // 모든 사용자
|
||||
}
|
||||
];
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'projects',
|
||||
title: '프로젝트 관리',
|
||||
icon: '📋',
|
||||
description: '프로젝트 등록 및 관리',
|
||||
requiredPermission: 'project_management'
|
||||
},
|
||||
{
|
||||
id: 'bom',
|
||||
title: 'BOM 관리',
|
||||
icon: '🔧',
|
||||
description: 'Bill of Materials 관리',
|
||||
requiredPermission: 'bom_management'
|
||||
},
|
||||
{
|
||||
id: 'materials',
|
||||
title: '자재 관리',
|
||||
icon: '📦',
|
||||
description: '자재 정보 및 재고 관리',
|
||||
requiredPermission: 'material_management'
|
||||
},
|
||||
{
|
||||
id: 'quotes',
|
||||
title: '견적 관리',
|
||||
icon: '💰',
|
||||
description: '견적서 작성 및 관리',
|
||||
requiredPermission: 'quote_management'
|
||||
},
|
||||
{
|
||||
id: 'procurement',
|
||||
title: '구매 관리',
|
||||
icon: '🛒',
|
||||
description: '구매 요청 및 발주 관리',
|
||||
requiredPermission: 'procurement_management'
|
||||
},
|
||||
{
|
||||
id: 'production',
|
||||
title: '생산 관리',
|
||||
icon: '🏭',
|
||||
description: '생산 계획 및 진행 관리',
|
||||
requiredPermission: 'production_management'
|
||||
},
|
||||
{
|
||||
id: 'shipment',
|
||||
title: '출하 관리',
|
||||
icon: '🚚',
|
||||
description: '출하 계획 및 배송 관리',
|
||||
requiredPermission: 'shipment_management'
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
title: '사용자 관리',
|
||||
icon: '👥',
|
||||
description: '사용자 계정 및 권한 관리',
|
||||
requiredPermission: 'user_management'
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
title: '시스템 설정',
|
||||
icon: '⚙️',
|
||||
description: '시스템 환경 설정',
|
||||
requiredPermission: 'system_admin'
|
||||
}
|
||||
];
|
||||
|
||||
// 사용자 권한에 따라 메뉴 필터링
|
||||
const userPermissions = user?.permissions || [];
|
||||
const filteredMenus = menuItems.filter(menu =>
|
||||
!menu.requiredPermission ||
|
||||
userPermissions.includes(menu.requiredPermission) ||
|
||||
user?.role === 'admin' // 관리자는 모든 메뉴 접근 가능
|
||||
);
|
||||
|
||||
return [...baseMenus, ...filteredMenus];
|
||||
};
|
||||
|
||||
const menuItems = getMenuItems();
|
||||
|
||||
const handleMenuClick = (menuId) => {
|
||||
onPageChange(menuId);
|
||||
setIsMenuOpen(false); // 모바일에서 메뉴 닫기
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="navigation-menu">
|
||||
{/* 모바일 햄버거 버튼 */}
|
||||
<button
|
||||
className="mobile-menu-toggle"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
aria-label="메뉴 토글"
|
||||
>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
|
||||
{/* 메뉴 오버레이 (모바일) */}
|
||||
{isMenuOpen && (
|
||||
<div
|
||||
className="menu-overlay"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 사이드바 메뉴 */}
|
||||
<nav className={`sidebar ${isMenuOpen ? 'open' : ''}`}>
|
||||
<div className="sidebar-header">
|
||||
<div className="logo">
|
||||
<span className="logo-icon">🚀</span>
|
||||
<div className="logo-text">
|
||||
<h2>TK-MP</h2>
|
||||
<span>통합 관리 시스템</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="menu-section">
|
||||
<div className="menu-section-title">메인 메뉴</div>
|
||||
<ul className="menu-list">
|
||||
{menuItems.map(item => (
|
||||
<li key={item.id} className="menu-item">
|
||||
<button
|
||||
className={`menu-button ${currentPage === item.id ? 'active' : ''}`}
|
||||
onClick={() => handleMenuClick(item.id)}
|
||||
title={item.description}
|
||||
>
|
||||
<span className="menu-icon">{item.icon}</span>
|
||||
<span className="menu-title">{item.title}</span>
|
||||
{currentPage === item.id && (
|
||||
<span className="active-indicator"></span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 사용자 정보 */}
|
||||
<div className="sidebar-footer">
|
||||
<div className="user-info">
|
||||
<div className="user-avatar">
|
||||
{user?.name?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div className="user-details">
|
||||
<div className="user-name">{user?.name}</div>
|
||||
<div className="user-role">{user?.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationMenu;
|
||||
159
frontend/src/components/ProtectedRoute.jsx
Normal file
159
frontend/src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import LoginPage from '../pages/LoginPage';
|
||||
|
||||
const ProtectedRoute = ({
|
||||
children,
|
||||
requiredPermission = null,
|
||||
requiredRole = null,
|
||||
fallback = null
|
||||
}) => {
|
||||
const { isAuthenticated, isLoading, user, hasPermission, hasRole } = useAuth();
|
||||
|
||||
// 로딩 중일 때
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="loading-container" style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
background: '#f7fafc',
|
||||
color: '#718096'
|
||||
}}>
|
||||
<div className="loading-spinner-large" style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
border: '4px solid #e2e8f0',
|
||||
borderTop: '4px solid #667eea',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
marginBottom: '16px'
|
||||
}}></div>
|
||||
<p>인증 정보를 확인하는 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 인증되지 않은 경우
|
||||
if (!isAuthenticated) {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
// 특정 권한이 필요한 경우
|
||||
if (requiredPermission && !hasPermission(requiredPermission)) {
|
||||
return fallback || (
|
||||
<div className="access-denied-container">
|
||||
<div className="access-denied-content">
|
||||
<div className="access-denied-icon">🔒</div>
|
||||
<h2>접근 권한이 없습니다</h2>
|
||||
<p>이 페이지에 접근하기 위한 권한이 없습니다.</p>
|
||||
<p className="permission-info">
|
||||
필요한 권한: <code>{requiredPermission}</code>
|
||||
</p>
|
||||
<div className="user-info">
|
||||
<p>현재 사용자: <strong>{user?.name}</strong> ({user?.username})</p>
|
||||
<p>역할: <strong>{user?.role}</strong></p>
|
||||
<p>접근 레벨: <strong>{user?.access_level}</strong></p>
|
||||
</div>
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
이전 페이지로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 특정 역할이 필요한 경우
|
||||
if (requiredRole && !hasRole(requiredRole)) {
|
||||
return fallback || (
|
||||
<div className="access-denied-container">
|
||||
<div className="access-denied-content">
|
||||
<div className="access-denied-icon">👤</div>
|
||||
<h2>역할 권한이 없습니다</h2>
|
||||
<p>이 페이지에 접근하기 위한 역할 권한이 없습니다.</p>
|
||||
<p className="role-info">
|
||||
필요한 역할: <code>{requiredRole}</code>
|
||||
</p>
|
||||
<div className="user-info">
|
||||
<p>현재 사용자: <strong>{user?.name}</strong> ({user?.username})</p>
|
||||
<p>현재 역할: <strong>{user?.role}</strong></p>
|
||||
</div>
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
이전 페이지로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 모든 조건을 만족하는 경우 자식 컴포넌트 렌더링
|
||||
return children;
|
||||
};
|
||||
|
||||
// 관리자 전용 라우트
|
||||
export const AdminRoute = ({ children, fallback = null }) => {
|
||||
return (
|
||||
<ProtectedRoute
|
||||
requiredRole="admin"
|
||||
fallback={fallback}
|
||||
>
|
||||
{children}
|
||||
</ProtectedRoute>
|
||||
);
|
||||
};
|
||||
|
||||
// 시스템 관리자 전용 라우트
|
||||
export const SystemRoute = ({ children, fallback = null }) => {
|
||||
const { hasRole } = useAuth();
|
||||
|
||||
if (!hasRole('admin') && !hasRole('system')) {
|
||||
return fallback || (
|
||||
<div className="access-denied-container">
|
||||
<div className="access-denied-content">
|
||||
<div className="access-denied-icon">⚙️</div>
|
||||
<h2>시스템 관리자 권한이 필요합니다</h2>
|
||||
<p>이 페이지는 시스템 관리자만 접근할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
{children}
|
||||
</ProtectedRoute>
|
||||
);
|
||||
};
|
||||
|
||||
// 매니저 이상 권한 라우트
|
||||
export const ManagerRoute = ({ children, fallback = null }) => {
|
||||
const { isManager } = useAuth();
|
||||
|
||||
if (!isManager()) {
|
||||
return fallback || (
|
||||
<div className="access-denied-container">
|
||||
<div className="access-denied-content">
|
||||
<div className="access-denied-icon">👔</div>
|
||||
<h2>관리자 권한이 필요합니다</h2>
|
||||
<p>이 페이지는 관리자 이상의 권한이 필요합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
{children}
|
||||
</ProtectedRoute>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
82
frontend/src/components/RevisionUploadDialog.jsx
Normal file
82
frontend/src/components/RevisionUploadDialog.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
|
||||
const RevisionUploadDialog = ({
|
||||
revisionDialog,
|
||||
setRevisionDialog,
|
||||
revisionFile,
|
||||
setRevisionFile,
|
||||
handleRevisionUpload,
|
||||
uploading
|
||||
}) => {
|
||||
if (!revisionDialog.open) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
maxWidth: '500px',
|
||||
width: '90%'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0' }}>
|
||||
리비전 업로드: {revisionDialog.bomName}
|
||||
</h3>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => setRevisionFile(e.target.files[0])}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginBottom: '16px',
|
||||
padding: '8px'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setRevisionDialog({ open: false, bomName: '', parentId: null })}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#e2e8f0',
|
||||
color: '#4a5568',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRevisionUpload}
|
||||
disabled={!revisionFile || uploading}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: (!revisionFile || uploading) ? '#e2e8f0' : '#4299e1',
|
||||
color: (!revisionFile || uploading) ? '#a0aec0' : 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: (!revisionFile || uploading) ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '업로드'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RevisionUploadDialog;
|
||||
301
frontend/src/components/SimpleFileUpload.jsx
Normal file
301
frontend/src/components/SimpleFileUpload.jsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import React, { useState } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
const SimpleFileUpload = ({ selectedProject, onUploadComplete }) => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadResult, setUploadResult] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
||||
const handleDrag = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleFileUpload(e.dataTransfer.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
handleFileUpload(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file) => {
|
||||
if (!selectedProject) {
|
||||
setError('프로젝트를 먼저 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 유효성 검사
|
||||
const allowedTypes = ['.xlsx', '.xls', '.csv'];
|
||||
const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
|
||||
|
||||
if (!allowedTypes.includes(fileExtension)) {
|
||||
setError(`지원하지 않는 파일 형식입니다. 허용된 확장자: ${allowedTypes.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setError('파일 크기는 10MB를 초과할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError('');
|
||||
setUploadResult(null);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('job_no', selectedProject.job_no);
|
||||
formData.append('revision', 'Rev.0');
|
||||
|
||||
// 업로드 진행률 시뮬레이션
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadProgress(prev => {
|
||||
if (prev >= 90) {
|
||||
clearInterval(progressInterval);
|
||||
return 90;
|
||||
}
|
||||
return prev + 10;
|
||||
});
|
||||
}, 200);
|
||||
|
||||
const response = await api.post('/files/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setUploadProgress(100);
|
||||
|
||||
if (response.data.success) {
|
||||
setUploadResult({
|
||||
success: true,
|
||||
message: response.data.message,
|
||||
file: response.data.file,
|
||||
job: response.data.job,
|
||||
sampleMaterials: response.data.sample_materials || []
|
||||
});
|
||||
|
||||
// 업로드 완료 콜백 호출
|
||||
if (onUploadComplete) {
|
||||
onUploadComplete(response.data);
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.data.message || '업로드 실패');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('업로드 에러:', err);
|
||||
setError(err.response?.data?.detail || err.message || '파일 업로드에 실패했습니다.');
|
||||
setUploadProgress(0);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 드래그 앤 드롭 영역 */}
|
||||
<div
|
||||
style={{
|
||||
border: `2px dashed ${dragActive ? '#667eea' : '#e2e8f0'}`,
|
||||
borderRadius: '12px',
|
||||
padding: '40px 20px',
|
||||
textAlign: 'center',
|
||||
background: dragActive ? '#f7fafc' : 'white',
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '20px'
|
||||
}}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => document.getElementById('file-input').click()}
|
||||
>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>
|
||||
{uploading ? '⏳' : '📤'}
|
||||
</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', marginBottom: '8px' }}>
|
||||
{uploading ? '업로드 중...' : 'BOM 파일을 업로드하세요'}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#718096', marginBottom: '16px' }}>
|
||||
파일을 드래그하거나 클릭하여 선택하세요
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#a0aec0' }}>
|
||||
지원 형식: Excel (.xlsx, .xls), CSV (.csv) | 최대 크기: 10MB
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 업로드 진행률 */}
|
||||
{uploading && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||
업로드 진행률
|
||||
</span>
|
||||
<span style={{ fontSize: '14px', color: '#667eea' }}>
|
||||
{uploadProgress}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '8px',
|
||||
background: '#e2e8f0',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${uploadProgress}%`,
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, #667eea, #764ba2)',
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fed7d7',
|
||||
border: '1px solid #fc8181',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
marginBottom: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span style={{ color: '#c53030', fontSize: '16px' }}>⚠️</span>
|
||||
<span style={{ color: '#c53030', fontSize: '14px' }}>{error}</span>
|
||||
<button
|
||||
onClick={() => setError('')}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#c53030',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드 성공 결과 */}
|
||||
{uploadResult && uploadResult.success && (
|
||||
<div style={{
|
||||
background: '#c6f6d5',
|
||||
border: '1px solid #68d391',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||
<span style={{ color: '#2f855a', fontSize: '20px' }}>✅</span>
|
||||
<span style={{ color: '#2f855a', fontSize: '16px', fontWeight: '600' }}>
|
||||
업로드 완료!
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ color: '#2f855a', fontSize: '14px', marginBottom: '16px' }}>
|
||||
{uploadResult.message}
|
||||
</div>
|
||||
|
||||
{/* 파일 정보 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px 0', color: '#2d3748', fontSize: '14px' }}>
|
||||
📄 파일 정보
|
||||
</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', fontSize: '12px' }}>
|
||||
<div><strong>파일명:</strong> {uploadResult.file?.original_filename}</div>
|
||||
<div><strong>분석된 자재:</strong> {uploadResult.file?.parsed_count}개</div>
|
||||
<div><strong>저장된 자재:</strong> {uploadResult.file?.saved_count}개</div>
|
||||
<div><strong>프로젝트:</strong> {uploadResult.job?.job_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 샘플 자재 미리보기 */}
|
||||
{uploadResult.sampleMaterials && uploadResult.sampleMaterials.length > 0 && (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '16px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px 0', color: '#2d3748', fontSize: '14px' }}>
|
||||
🔧 자재 샘플 (처음 3개)
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{uploadResult.sampleMaterials.map((material, index) => (
|
||||
<div key={index} style={{
|
||||
padding: '8px 12px',
|
||||
background: '#f7fafc',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
color: '#4a5568'
|
||||
}}>
|
||||
<strong>{material.description || material.item_code}</strong>
|
||||
{material.category && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#667eea',
|
||||
color: 'white',
|
||||
borderRadius: '3px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{material.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleFileUpload;
|
||||
263
frontend/src/contexts/AuthContext.jsx
Normal file
263
frontend/src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
// 토큰 관리
|
||||
const getToken = () => localStorage.getItem('access_token');
|
||||
const getRefreshToken = () => localStorage.getItem('refresh_token');
|
||||
|
||||
const setTokens = (accessToken, refreshToken) => {
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
}
|
||||
};
|
||||
|
||||
const clearTokens = () => {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user_data');
|
||||
};
|
||||
|
||||
// 사용자 권한 확인
|
||||
const hasPermission = (permission) => {
|
||||
if (!user || !user.permissions) return false;
|
||||
return user.permissions.includes(permission);
|
||||
};
|
||||
|
||||
const hasRole = (role) => {
|
||||
if (!user) return false;
|
||||
return user.role === role;
|
||||
};
|
||||
|
||||
const isAdmin = () => hasRole('admin') || hasRole('system');
|
||||
const isManager = () => hasRole('admin') || hasRole('system') || hasRole('leader');
|
||||
|
||||
// 로그인
|
||||
const login = async (username, password) => {
|
||||
try {
|
||||
const response = await api.post('/auth/login', {
|
||||
username,
|
||||
password
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
const { access_token, refresh_token, user: userData } = response.data;
|
||||
|
||||
setTokens(access_token, refresh_token);
|
||||
setUser(userData);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
// 사용자 데이터 로컬 저장
|
||||
localStorage.setItem('user_data', JSON.stringify(userData));
|
||||
|
||||
return userData;
|
||||
} else {
|
||||
throw new Error(response.data.error?.message || '로그인에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
|
||||
if (error.response?.data?.error?.message) {
|
||||
throw new Error(error.response.data.error.message);
|
||||
} else if (error.response?.status === 401) {
|
||||
throw new Error('아이디 또는 비밀번호가 올바르지 않습니다.');
|
||||
} else if (error.response?.status >= 500) {
|
||||
throw new Error('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
|
||||
} else {
|
||||
throw new Error('로그인에 실패했습니다. 네트워크 연결을 확인해주세요.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 로그아웃
|
||||
const logout = async () => {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
await api.post('/auth/logout');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 토큰 갱신
|
||||
const refreshAccessToken = async () => {
|
||||
try {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const response = await api.post('/auth/refresh', {
|
||||
refresh_token: refreshToken
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
const { access_token } = response.data;
|
||||
setTokens(access_token);
|
||||
return access_token;
|
||||
} else {
|
||||
throw new Error('Token refresh failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token refresh error:', error);
|
||||
await logout();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 현재 사용자 정보 조회
|
||||
const getCurrentUser = async () => {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
const response = await api.get('/auth/me');
|
||||
if (response.data.success) {
|
||||
const userData = response.data.user;
|
||||
setUser(userData);
|
||||
setIsAuthenticated(true);
|
||||
localStorage.setItem('user_data', JSON.stringify(userData));
|
||||
return userData;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Get current user error:', error);
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
// 토큰이 만료되었거나 인증 실패한 경우 갱신 시도
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
return await getCurrentUser();
|
||||
} catch (refreshError) {
|
||||
await logout();
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 인증 상태 확인
|
||||
useEffect(() => {
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
const token = getToken();
|
||||
const savedUserData = localStorage.getItem('user_data');
|
||||
|
||||
if (token && savedUserData) {
|
||||
try {
|
||||
const userData = JSON.parse(savedUserData);
|
||||
setUser(userData);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
// 백그라운드에서 토큰 유효성 검증 (선택적)
|
||||
getCurrentUser().catch(error => {
|
||||
console.warn('Background token validation failed:', error);
|
||||
// 백그라운드 검증 실패는 무시 (사용자 경험 우선)
|
||||
});
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse saved user data:', parseError);
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
} else {
|
||||
// 토큰이나 사용자 데이터가 없으면 로그아웃 상태로 설정
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth initialization error:', error);
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
}, []);
|
||||
|
||||
// API 요청 인터셉터 설정
|
||||
useEffect(() => {
|
||||
const requestInterceptor = api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
const responseInterceptor = api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
const token = getToken();
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
await logout();
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
api.interceptors.request.eject(requestInterceptor);
|
||||
api.interceptors.response.eject(responseInterceptor);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
getCurrentUser,
|
||||
hasPermission,
|
||||
hasRole,
|
||||
isAdmin,
|
||||
isManager,
|
||||
refreshAccessToken
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
431
frontend/src/pages/BOMManagementPage.jsx
Normal file
431
frontend/src/pages/BOMManagementPage.jsx
Normal file
@@ -0,0 +1,431 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import SimpleFileUpload from '../components/SimpleFileUpload';
|
||||
import MaterialList from '../components/MaterialList';
|
||||
import { fetchMaterials, fetchFiles } from '../api';
|
||||
|
||||
const BOMManagementPage = ({ user }) => {
|
||||
const [activeTab, setActiveTab] = useState('upload');
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [selectedProject, setSelectedProject] = useState(null);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState({
|
||||
totalFiles: 0,
|
||||
totalMaterials: 0,
|
||||
recentUploads: []
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
loadProjectFiles();
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, [files, materials]);
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/jobs/');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setProjects(data.jobs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로딩 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadProjectFiles = async () => {
|
||||
if (!selectedProject) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// 기존 API 함수 사용 - 파일 목록 로딩
|
||||
const filesResponse = await fetchFiles({ job_no: selectedProject.job_no });
|
||||
setFiles(Array.isArray(filesResponse.data) ? filesResponse.data : []);
|
||||
|
||||
// 기존 API 함수 사용 - 자재 목록 로딩
|
||||
const materialsResponse = await fetchMaterials({ job_no: selectedProject.job_no, limit: 1000 });
|
||||
setMaterials(materialsResponse.data?.materials || []);
|
||||
} catch (error) {
|
||||
console.error('프로젝트 데이터 로딩 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
// 실제 통계 계산 - 더미 데이터 없이
|
||||
const totalFiles = files.length;
|
||||
const totalMaterials = materials.length;
|
||||
|
||||
setStats({
|
||||
totalFiles,
|
||||
totalMaterials,
|
||||
recentUploads: files.slice(0, 5) // 최근 5개 파일
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('통계 로딩 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (uploadData) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// 기존 FileUpload 컴포넌트의 업로드 로직 활용
|
||||
await loadProjectFiles(); // 업로드 후 데이터 새로고침
|
||||
await loadStats();
|
||||
} catch (error) {
|
||||
console.error('파일 업로드 후 새로고침 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const StatCard = ({ title, value, icon, color = '#667eea' }) => (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e2e8f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '12px',
|
||||
background: color + '20',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '24px'
|
||||
}}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{value}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
🔧 BOM 관리
|
||||
</h1>
|
||||
<p style={{
|
||||
color: '#718096',
|
||||
fontSize: '16px',
|
||||
margin: '0'
|
||||
}}>
|
||||
Bill of Materials 업로드, 분석 및 관리를 수행하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드들 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||
gap: '20px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<StatCard
|
||||
title="총 업로드 파일"
|
||||
value={stats.totalFiles}
|
||||
icon="📄"
|
||||
color="#667eea"
|
||||
/>
|
||||
<StatCard
|
||||
title="분석된 자재"
|
||||
value={stats.totalMaterials}
|
||||
icon="🔧"
|
||||
color="#48bb78"
|
||||
/>
|
||||
<StatCard
|
||||
title="활성 프로젝트"
|
||||
value={projects.length}
|
||||
icon="📋"
|
||||
color="#ed8936"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
overflow: 'hidden',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
borderBottom: '1px solid #e2e8f0'
|
||||
}}>
|
||||
{[
|
||||
{ id: 'upload', label: '📤 파일 업로드', icon: '📤' },
|
||||
{ id: 'files', label: '📁 파일 관리', icon: '📁' },
|
||||
{ id: 'materials', label: '🔧 자재 목록', icon: '🔧' },
|
||||
{ id: 'analysis', label: '📊 분석 결과', icon: '📊' }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '16px 20px',
|
||||
background: activeTab === tab.id ? '#f7fafc' : 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === tab.id ? '2px solid #667eea' : '2px solid transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: activeTab === tab.id ? '600' : '500',
|
||||
color: activeTab === tab.id ? '#667eea' : '#4a5568',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
<div style={{ padding: '24px' }}>
|
||||
{activeTab === 'upload' && (
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
📤 BOM 파일 업로드
|
||||
</h3>
|
||||
|
||||
{/* 프로젝트 선택 */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
프로젝트 선택
|
||||
</label>
|
||||
<select
|
||||
value={selectedProject?.job_no || ''}
|
||||
onChange={(e) => {
|
||||
const project = projects.find(p => p.job_no === e.target.value);
|
||||
setSelectedProject(project);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
background: 'white'
|
||||
}}
|
||||
>
|
||||
<option value="">프로젝트를 선택하세요</option>
|
||||
{projects.map(project => (
|
||||
<option key={project.job_no} value={project.job_no}>
|
||||
{project.job_no} - {project.job_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedProject ? (
|
||||
<div>
|
||||
<div style={{
|
||||
background: '#f7fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 8px 0', color: '#2d3748' }}>
|
||||
선택된 프로젝트: {selectedProject.job_name}
|
||||
</h4>
|
||||
<p style={{ margin: '0', fontSize: '14px', color: '#718096' }}>
|
||||
Job No: {selectedProject.job_no} |
|
||||
고객사: {selectedProject.client_name} |
|
||||
상태: {selectedProject.status}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SimpleFileUpload
|
||||
selectedProject={selectedProject}
|
||||
onUploadComplete={handleFileUpload}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
먼저 프로젝트를 선택해주세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'files' && (
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
📁 업로드된 파일 목록
|
||||
</h3>
|
||||
|
||||
{selectedProject ? (
|
||||
loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
|
||||
파일 목록을 불러오는 중...
|
||||
</div>
|
||||
) : files.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{files.map((file, index) => (
|
||||
<div key={index} style={{
|
||||
background: '#f7fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontWeight: '600', color: '#2d3748' }}>
|
||||
{file.original_filename || file.filename}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#718096' }}>
|
||||
업로드: {new Date(file.created_at).toLocaleString()} |
|
||||
자재 수: {file.parsed_count || 0}개
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '4px 8px',
|
||||
background: '#48bb78',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
{file.revision || 'Rev.0'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
|
||||
업로드된 파일이 없습니다.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
|
||||
프로젝트를 선택해주세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'materials' && (
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
🔧 자재 목록
|
||||
</h3>
|
||||
|
||||
{selectedProject ? (
|
||||
<MaterialList
|
||||
selectedProject={selectedProject}
|
||||
key={selectedProject.job_no} // 프로젝트 변경 시 컴포넌트 재렌더링
|
||||
/>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
|
||||
프로젝트를 선택해주세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'analysis' && (
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
📊 분석 결과
|
||||
</h3>
|
||||
|
||||
<div style={{
|
||||
background: '#fff3cd',
|
||||
border: '1px solid #ffeaa7',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '16px', color: '#856404' }}>
|
||||
🚧 분석 결과 페이지는 곧 구현될 예정입니다.
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#856404', marginTop: '8px' }}>
|
||||
자재 분류, 통계, 비교 분석 기능이 추가됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMManagementPage;
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, CircularProgress, Alert, TextField, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { uploadFile as uploadFileApi, fetchFiles as fetchFilesApi, deleteFile as deleteFileApi } from '../api';
|
||||
import { uploadFile as uploadFileApi, fetchFiles as fetchFilesApi, deleteFile as deleteFileApi, api } from '../api';
|
||||
import BOMFileUpload from '../components/BOMFileUpload';
|
||||
import BOMFileTable from '../components/BOMFileTable';
|
||||
import RevisionUploadDialog from '../components/RevisionUploadDialog';
|
||||
|
||||
const BOMStatusPage = () => {
|
||||
const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -12,10 +13,24 @@ const BOMStatusPage = () => {
|
||||
const [bomName, setBomName] = useState('');
|
||||
const [revisionDialog, setRevisionDialog] = useState({ open: false, bomName: '', parentId: null });
|
||||
const [revisionFile, setRevisionFile] = useState(null);
|
||||
const [searchParams] = useSearchParams();
|
||||
const jobNo = searchParams.get('job_no');
|
||||
const jobName = searchParams.get('job_name');
|
||||
const navigate = useNavigate();
|
||||
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;
|
||||
};
|
||||
|
||||
// 파일 목록 불러오기
|
||||
const fetchFiles = async () => {
|
||||
@@ -26,134 +41,167 @@ const BOMStatusPage = () => {
|
||||
const response = await fetchFilesApi({ job_no: jobNo });
|
||||
console.log('API 응답:', response);
|
||||
|
||||
if (Array.isArray(response.data)) {
|
||||
console.log('데이터 배열 형태:', response.data.length, '개');
|
||||
if (response.data && response.data.data && Array.isArray(response.data.data)) {
|
||||
setFiles(response.data.data);
|
||||
} else if (response.data && Array.isArray(response.data)) {
|
||||
setFiles(response.data);
|
||||
} else if (response.data && Array.isArray(response.data.files)) {
|
||||
console.log('데이터.files 배열 형태:', response.data.files.length, '개');
|
||||
setFiles(response.data.files);
|
||||
} else {
|
||||
console.log('빈 배열로 설정');
|
||||
setFiles([]);
|
||||
}
|
||||
} catch (e) {
|
||||
setError('파일 목록을 불러오지 못했습니다.');
|
||||
console.error('파일 목록 로드 에러:', e);
|
||||
} catch (err) {
|
||||
console.error('파일 목록 불러오기 실패:', err);
|
||||
setError('파일 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log('useEffect 실행 - jobNo:', jobNo);
|
||||
if (jobNo) {
|
||||
fetchFiles();
|
||||
} else {
|
||||
console.log('jobNo가 없어서 fetchFiles 실행하지 않음');
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [jobNo]);
|
||||
|
||||
// BOM 이름 중복 체크
|
||||
const checkDuplicateBOM = () => {
|
||||
return files.some(file =>
|
||||
file.bom_name === bomName ||
|
||||
file.original_filename === bomName ||
|
||||
file.filename === bomName
|
||||
);
|
||||
};
|
||||
|
||||
// 파일 업로드 핸들러
|
||||
// 파일 업로드
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) {
|
||||
setError('파일을 선택해주세요.');
|
||||
if (!selectedFile || !bomName.trim()) {
|
||||
setError('파일과 BOM 이름을 모두 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bomName.trim()) {
|
||||
setError('BOM 이름을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setUploading(true);
|
||||
setError('');
|
||||
|
||||
|
||||
try {
|
||||
const isDuplicate = checkDuplicateBOM();
|
||||
if (isDuplicate && !confirm(`"${bomName}"은(는) 이미 존재하는 BOM입니다. 새로운 리비전으로 업로드하시겠습니까?`)) {
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('job_no', jobNo);
|
||||
formData.append('revision', 'Rev.0');
|
||||
formData.append('bom_name', bomName);
|
||||
formData.append('bom_type', 'excel');
|
||||
formData.append('description', '');
|
||||
formData.append('bom_name', bomName.trim());
|
||||
|
||||
const uploadResult = await uploadFileApi(formData);
|
||||
|
||||
const response = await uploadFileApi(formData);
|
||||
// 업로드 성공 후 파일 목록 새로고침
|
||||
await fetchFiles();
|
||||
|
||||
if (response.data.success) {
|
||||
setSelectedFile(null);
|
||||
setBomName('');
|
||||
// 파일 input 초기화
|
||||
const fileInput = document.getElementById('file-input');
|
||||
if (fileInput) fileInput.value = '';
|
||||
|
||||
fetchFiles();
|
||||
alert(`업로드 성공! ${response.data.materials_count || 0}개의 자재가 분류되었습니다.\n리비전: ${response.data.revision || 'Rev.0'}`);
|
||||
} else {
|
||||
setError(response.data.message || '업로드에 실패했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('업로드 에러:', e);
|
||||
if (e.response?.data?.detail) {
|
||||
setError(e.response.data.detail);
|
||||
} else {
|
||||
setError('파일 업로드에 실패했습니다.');
|
||||
// 업로드 완료 후 자동으로 구매 수량 계산 실행
|
||||
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초 후 실행 (분류 완료 대기)
|
||||
}
|
||||
|
||||
// 폼 초기화
|
||||
setSelectedFile(null);
|
||||
setBomName('');
|
||||
document.getElementById('file-input').value = '';
|
||||
|
||||
} catch (err) {
|
||||
console.error('파일 업로드 실패:', err);
|
||||
setError('파일 업로드에 실패했습니다.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 리비전 업로드 핸들러
|
||||
// 파일 삭제
|
||||
const handleDelete = async (fileId) => {
|
||||
if (!window.confirm('정말로 이 파일을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteFileApi(fileId);
|
||||
await fetchFiles(); // 목록 새로고침
|
||||
} 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 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) {
|
||||
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('revision', 'Rev.0'); // 백엔드에서 자동 증가
|
||||
formData.append('bom_name', revisionDialog.bomName);
|
||||
formData.append('parent_file_id', revisionDialog.parentId);
|
||||
formData.append('parent_id', revisionDialog.parentId);
|
||||
|
||||
await uploadFileApi(formData);
|
||||
|
||||
const response = await uploadFileApi(formData);
|
||||
// 업로드 성공 후 파일 목록 새로고침
|
||||
await fetchFiles();
|
||||
|
||||
if (response.data.success) {
|
||||
setRevisionDialog({ open: false, bomName: '', parentId: null });
|
||||
setRevisionFile(null);
|
||||
fetchFiles();
|
||||
alert(`리비전 업로드 성공! ${response.data.materials_count || 0}개의 자재가 분류되었습니다.\n리비전: ${response.data.revision}`);
|
||||
} else {
|
||||
setError(response.data.message || '리비전 업로드에 실패했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('리비전 업로드 에러:', e);
|
||||
if (e.response?.data?.detail) {
|
||||
setError(e.response.data.detail);
|
||||
} else {
|
||||
setError('리비전 업로드에 실패했습니다.');
|
||||
}
|
||||
// 다이얼로그 닫기
|
||||
setRevisionDialog({ open: false, bomName: '', parentId: null });
|
||||
setRevisionFile(null);
|
||||
|
||||
} catch (err) {
|
||||
console.error('리비전 업로드 실패:', err);
|
||||
setError('리비전 업로드에 실패했습니다.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
@@ -183,236 +231,274 @@ const BOMStatusPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 900, mx: 'auto', mt: 4 }}>
|
||||
<Button variant="outlined" onClick={() => navigate('/')} sx={{ mb: 2 }}>
|
||||
← 뒤로가기
|
||||
</Button>
|
||||
<Typography variant="h4" gutterBottom>📊 BOM 관리 시스템</Typography>
|
||||
{jobNo && jobName && (
|
||||
<Typography variant="h6" color="primary" sx={{ mb: 3 }}>
|
||||
{jobNo} - {jobName}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 파일 업로드 폼 */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>새 BOM 업로드</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="BOM 이름"
|
||||
value={bomName}
|
||||
onChange={(e) => setBomName(e.target.value)}
|
||||
placeholder="예: PIPING_BOM_A구역"
|
||||
required
|
||||
size="small"
|
||||
helperText="동일한 BOM 이름으로 재업로드 시 리비전이 자동 증가합니다"
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => setSelectedFile(e.target.files[0])}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || !bomName.trim() || uploading}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '업로드'}
|
||||
</Button>
|
||||
</Box>
|
||||
{selectedFile && (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
선택된 파일: {selectedFile.name}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
<Typography variant="h6" sx={{ mt: 4, mb: 2 }}>업로드된 BOM 목록</Typography>
|
||||
{loading && <CircularProgress />}
|
||||
{!loading && files.length === 0 && (
|
||||
<Alert severity="info">업로드된 BOM이 없습니다.</Alert>
|
||||
)}
|
||||
{!loading && files.length > 0 && (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>BOM 이름</TableCell>
|
||||
<TableCell>파일명</TableCell>
|
||||
<TableCell>리비전</TableCell>
|
||||
<TableCell>자재 수</TableCell>
|
||||
<TableCell>업로드 일시</TableCell>
|
||||
<TableCell>작업</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Object.entries(groupFilesByBOM()).map(([bomKey, bomFiles]) => (
|
||||
bomFiles.map((file, index) => (
|
||||
<TableRow key={file.id} sx={{
|
||||
backgroundColor: index === 0 ? 'rgba(25, 118, 210, 0.08)' : 'rgba(0, 0, 0, 0.02)'
|
||||
}}>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight={index === 0 ? 'bold' : 'normal'}>
|
||||
{file.bom_name || bomKey}
|
||||
</Typography>
|
||||
{index === 0 && bomFiles.length > 1 && (
|
||||
<Typography variant="caption" color="primary">
|
||||
(최신 리비전)
|
||||
</Typography>
|
||||
)}
|
||||
{index > 0 && (
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
(이전 버전)
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
|
||||
{file.filename || file.original_filename}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={index === 0 ? 'primary' : 'textSecondary'}
|
||||
fontWeight={index === 0 ? 'bold' : 'normal'}
|
||||
>
|
||||
{file.revision || 'Rev.0'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
|
||||
{file.parsed_count || 0}개
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
|
||||
{file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
size="small"
|
||||
variant={index === 0 ? "contained" : "outlined"}
|
||||
onClick={() => navigate(`/materials?file_id=${file.id}&job_no=${jobNo}&filename=${encodeURIComponent(file.filename || file.original_filename)}`)}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
자재확인
|
||||
</Button>
|
||||
{index === 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => setRevisionDialog({
|
||||
open: true,
|
||||
bomName: file.bom_name || bomKey,
|
||||
parentId: file.id
|
||||
})}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
리비전
|
||||
</Button>
|
||||
)}
|
||||
{file.revision !== 'Rev.0' && index < 3 && (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => navigate(`/material-comparison?job_no=${jobNo}&revision=${file.revision}&filename=${encodeURIComponent(file.original_filename)}`)}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
비교
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="success"
|
||||
onClick={() => navigate(`/revision-purchase?job_no=${jobNo}¤t_revision=${file.revision}&bom_name=${encodeURIComponent(file.bom_name || bomKey)}`)}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
구매 필요
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={async () => {
|
||||
if (confirm(`정말 "${file.revision || 'Rev.0'}"을 삭제하시겠습니까?`)) {
|
||||
try {
|
||||
const response = await deleteFileApi(file.id);
|
||||
if (response.data.success) {
|
||||
fetchFiles();
|
||||
alert('삭제되었습니다.');
|
||||
} else {
|
||||
alert('삭제 실패: ' + (response.data.message || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('삭제 오류:', e);
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{/* 리비전 업로드 다이얼로그 */}
|
||||
<Dialog open={revisionDialog.open} onClose={() => setRevisionDialog({ open: false, bomName: '', parentId: null })}>
|
||||
<DialogTitle>리비전 업로드</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
BOM 이름: <strong>{revisionDialog.bomName}</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
새로운 리비전 파일을 선택하세요. 리비전 번호는 자동으로 증가합니다.
|
||||
</Typography>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => setRevisionFile(e.target.files[0])}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
{revisionFile && (
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
선택된 파일: {revisionFile.name}
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => {
|
||||
setRevisionDialog({ open: false, bomName: '', parentId: null });
|
||||
setRevisionFile(null);
|
||||
}}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleRevisionUpload}
|
||||
disabled={!revisionFile || uploading}
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<button
|
||||
onClick={() => onNavigate && onNavigate('bom')}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '업로드'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
← 뒤로가기
|
||||
</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>
|
||||
|
||||
{/* 파일 테이블 컴포넌트 */}
|
||||
<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 && (
|
||||
<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={() => 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),
|
||||
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 BOMStatusPage;
|
||||
export default BOMStatusPage;
|
||||
264
frontend/src/pages/DashboardPage.jsx
Normal file
264
frontend/src/pages/DashboardPage.jsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const DashboardPage = ({ user }) => {
|
||||
const [stats, setStats] = useState({
|
||||
totalProjects: 0,
|
||||
activeProjects: 0,
|
||||
completedProjects: 0,
|
||||
totalMaterials: 0,
|
||||
pendingQuotes: 0,
|
||||
recentActivities: []
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// 실제로는 API에서 데이터를 가져올 예정
|
||||
// 현재는 더미 데이터 사용
|
||||
setStats({
|
||||
totalProjects: 25,
|
||||
activeProjects: 8,
|
||||
completedProjects: 17,
|
||||
totalMaterials: 1250,
|
||||
pendingQuotes: 3,
|
||||
recentActivities: [
|
||||
{ id: 1, type: 'project', message: '냉동기 프로젝트 #2024-001 생성됨', time: '2시간 전' },
|
||||
{ id: 2, type: 'bom', message: 'BOG 시스템 BOM 업데이트됨', time: '4시간 전' },
|
||||
{ id: 3, type: 'quote', message: '다이아프람 펌프 견적서 승인됨', time: '6시간 전' },
|
||||
{ id: 4, type: 'material', message: '스테인리스 파이프 재고 부족 알림', time: '1일 전' },
|
||||
{ id: 5, type: 'shipment', message: '드라이어 시스템 출하 완료', time: '2일 전' }
|
||||
]
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getActivityIcon = (type) => {
|
||||
const icons = {
|
||||
project: '📋',
|
||||
bom: '🔧',
|
||||
quote: '💰',
|
||||
material: '📦',
|
||||
shipment: '🚚'
|
||||
};
|
||||
return icons[type] || '📌';
|
||||
};
|
||||
|
||||
const StatCard = ({ title, value, icon, color = '#667eea' }) => (
|
||||
<div 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'
|
||||
}}
|
||||
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'
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '32px',
|
||||
opacity: 0.8
|
||||
}}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
안녕하세요, {user?.name}님! 👋
|
||||
</h1>
|
||||
<p style={{
|
||||
color: '#718096',
|
||||
fontSize: '16px',
|
||||
margin: '0'
|
||||
}}>
|
||||
오늘도 TK-MP 시스템과 함께 효율적인 업무를 시작해보세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드들 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
|
||||
gap: '24px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<StatCard
|
||||
title="전체 프로젝트"
|
||||
value={stats.totalProjects}
|
||||
icon="📋"
|
||||
color="#667eea"
|
||||
/>
|
||||
<StatCard
|
||||
title="진행중인 프로젝트"
|
||||
value={stats.activeProjects}
|
||||
icon="🚀"
|
||||
color="#48bb78"
|
||||
/>
|
||||
<StatCard
|
||||
title="완료된 프로젝트"
|
||||
value={stats.completedProjects}
|
||||
icon="✅"
|
||||
color="#38b2ac"
|
||||
/>
|
||||
<StatCard
|
||||
title="등록된 자재"
|
||||
value={stats.totalMaterials}
|
||||
icon="📦"
|
||||
color="#ed8936"
|
||||
/>
|
||||
<StatCard
|
||||
title="대기중인 견적"
|
||||
value={stats.pendingQuotes}
|
||||
icon="💰"
|
||||
color="#9f7aea"
|
||||
/>
|
||||
</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: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{stats.recentActivities.map(activity => (
|
||||
<div key={activity.id} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '12px',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
background: '#f7fafc',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<span style={{ fontSize: '16px' }}>
|
||||
{getActivityIcon(activity.type)}
|
||||
</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 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' }}>
|
||||
{[
|
||||
{ title: '새 프로젝트 등록', icon: '➕', color: '#667eea' },
|
||||
{ title: 'BOM 업로드', icon: '📤', color: '#48bb78' },
|
||||
{ title: '견적서 작성', icon: '📝', color: '#ed8936' },
|
||||
{ title: '자재 검색', icon: '🔍', color: '#38b2ac' }
|
||||
].map((action, index) => (
|
||||
<button key={index} 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'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = '#f7fafc';
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
334
frontend/src/pages/JobRegistrationPage.css
Normal file
334
frontend/src/pages/JobRegistrationPage.css
Normal file
@@ -0,0 +1,334 @@
|
||||
.job-registration-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.job-registration-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px 40px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 2rem;
|
||||
margin: 0 0 10px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.registration-form {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 25px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-group label.required::after {
|
||||
content: ' *';
|
||||
color: #e53e3e;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.form-group input.error,
|
||||
.form-group select.error,
|
||||
.form-group textarea.error {
|
||||
border-color: #e53e3e;
|
||||
}
|
||||
|
||||
.form-group input::placeholder,
|
||||
.form-group textarea::placeholder {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #e53e3e;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 30px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.cancel-button,
|
||||
.submit-button {
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background: #f7fafc;
|
||||
color: #4a5568;
|
||||
border: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background: #edf2f7;
|
||||
border-color: #cbd5e0;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 모바일 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.job-registration-page {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.registration-form {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.cancel-button,
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 프로젝트 유형 관리 스타일 */
|
||||
.project-type-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.project-type-container select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.project-type-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.add-type-btn,
|
||||
.remove-type-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid #e2e8f0;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-type-btn {
|
||||
color: #38a169;
|
||||
border-color: #38a169;
|
||||
}
|
||||
|
||||
.add-type-btn:hover {
|
||||
background: #38a169;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.remove-type-btn {
|
||||
color: #e53e3e;
|
||||
border-color: #e53e3e;
|
||||
}
|
||||
|
||||
.remove-type-btn:hover {
|
||||
background: #e53e3e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.add-project-type-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background: #f7fafc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.add-project-type-form input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd5e0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.add-project-type-form button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-project-type-form button:first-of-type {
|
||||
background: #38a169;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.add-project-type-form button:first-of-type:hover {
|
||||
background: #2f855a;
|
||||
}
|
||||
|
||||
.add-project-type-form button:last-of-type {
|
||||
background: #e2e8f0;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.add-project-type-form button:last-of-type:hover {
|
||||
background: #cbd5e0;
|
||||
}
|
||||
|
||||
/* 태블릿 반응형 */
|
||||
@media (max-width: 1024px) and (min-width: 769px) {
|
||||
.job-registration-container {
|
||||
margin: 20px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 모바일에서 프로젝트 유형 관리 */
|
||||
@media (max-width: 768px) {
|
||||
.project-type-container {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.project-type-actions {
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.add-project-type-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.add-project-type-form button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
359
frontend/src/pages/JobRegistrationPage.jsx
Normal file
359
frontend/src/pages/JobRegistrationPage.jsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api';
|
||||
import './JobRegistrationPage.css';
|
||||
|
||||
const JobRegistrationPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
jobNo: '',
|
||||
projectName: '',
|
||||
clientName: '',
|
||||
location: '',
|
||||
contractDate: '',
|
||||
deliveryDate: '',
|
||||
deliveryMethod: '',
|
||||
description: '',
|
||||
projectType: '냉동기',
|
||||
status: 'PLANNING'
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const [projectTypes, setProjectTypes] = useState([
|
||||
{ value: '냉동기', label: '냉동기' },
|
||||
{ value: 'BOG', label: 'BOG' },
|
||||
{ value: '다이아프람', label: '다이아프람' },
|
||||
{ value: '드라이어', label: '드라이어' }
|
||||
]);
|
||||
|
||||
const [newProjectType, setNewProjectType] = useState('');
|
||||
const [showAddProjectType, setShowAddProjectType] = useState(false);
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'PLANNING', label: '계획' },
|
||||
{ value: 'DESIGN', label: '설계' },
|
||||
{ value: 'PROCUREMENT', label: '조달' },
|
||||
{ value: 'CONSTRUCTION', label: '시공' },
|
||||
{ value: 'COMPLETED', label: '완료' }
|
||||
];
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// 입력 시 에러 제거
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: ''
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const addProjectType = () => {
|
||||
if (newProjectType.trim() && !projectTypes.find(type => type.value === newProjectType.trim())) {
|
||||
const newType = { value: newProjectType.trim(), label: newProjectType.trim() };
|
||||
setProjectTypes(prev => [...prev, newType]);
|
||||
setFormData(prev => ({ ...prev, projectType: newProjectType.trim() }));
|
||||
setNewProjectType('');
|
||||
setShowAddProjectType(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeProjectType = (valueToRemove) => {
|
||||
if (projectTypes.length > 1) { // 최소 1개는 유지
|
||||
setProjectTypes(prev => prev.filter(type => type.value !== valueToRemove));
|
||||
if (formData.projectType === valueToRemove) {
|
||||
setFormData(prev => ({ ...prev, projectType: projectTypes[0].value }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.jobNo.trim()) {
|
||||
newErrors.jobNo = 'Job No.는 필수 입력 항목입니다.';
|
||||
}
|
||||
|
||||
if (!formData.projectName.trim()) {
|
||||
newErrors.projectName = '프로젝트명은 필수 입력 항목입니다.';
|
||||
}
|
||||
|
||||
if (!formData.clientName.trim()) {
|
||||
newErrors.clientName = '고객사명은 필수 입력 항목입니다.';
|
||||
}
|
||||
|
||||
if (formData.contractDate && formData.deliveryDate && new Date(formData.contractDate) > new Date(formData.deliveryDate)) {
|
||||
newErrors.deliveryDate = '납기일은 수주일 이후여야 합니다.';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Job 생성 API 호출
|
||||
const response = await api.post('/jobs', {
|
||||
job_no: formData.jobNo,
|
||||
job_name: formData.projectName,
|
||||
client_name: formData.clientName,
|
||||
project_site: formData.location || null,
|
||||
contract_date: formData.contractDate || null,
|
||||
delivery_date: formData.deliveryDate || null,
|
||||
delivery_terms: formData.deliveryMethod || null,
|
||||
description: formData.description || null,
|
||||
project_type: formData.projectType,
|
||||
status: formData.status
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
alert('프로젝트가 성공적으로 등록되었습니다!');
|
||||
navigate('/project-selection');
|
||||
} else {
|
||||
alert('등록에 실패했습니다: ' + response.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Job 등록 오류:', error);
|
||||
if (error.response?.data?.detail) {
|
||||
alert('등록 실패: ' + error.response.data.detail);
|
||||
} else {
|
||||
alert('등록 중 오류가 발생했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="job-registration-page">
|
||||
<div className="job-registration-container">
|
||||
<header className="page-header">
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
← 메인으로 돌아가기
|
||||
</button>
|
||||
<h1>프로젝트 기본정보 등록</h1>
|
||||
<p>새로운 프로젝트의 Job No. 및 기본 정보를 입력해주세요</p>
|
||||
</header>
|
||||
|
||||
<form className="registration-form" onSubmit={handleSubmit}>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label htmlFor="jobNo" className="required">Job No.</label>
|
||||
<input
|
||||
type="text"
|
||||
id="jobNo"
|
||||
name="jobNo"
|
||||
value={formData.jobNo}
|
||||
onChange={handleInputChange}
|
||||
placeholder="예: TK-2025-001"
|
||||
className={errors.jobNo ? 'error' : ''}
|
||||
/>
|
||||
{errors.jobNo && <span className="error-message">{errors.jobNo}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="projectName" className="required">프로젝트명</label>
|
||||
<input
|
||||
type="text"
|
||||
id="projectName"
|
||||
name="projectName"
|
||||
value={formData.projectName}
|
||||
onChange={handleInputChange}
|
||||
placeholder="프로젝트명을 입력하세요"
|
||||
className={errors.projectName ? 'error' : ''}
|
||||
/>
|
||||
{errors.projectName && <span className="error-message">{errors.projectName}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="clientName" className="required">고객사명</label>
|
||||
<input
|
||||
type="text"
|
||||
id="clientName"
|
||||
name="clientName"
|
||||
value={formData.clientName}
|
||||
onChange={handleInputChange}
|
||||
placeholder="고객사명을 입력하세요"
|
||||
className={errors.clientName ? 'error' : ''}
|
||||
/>
|
||||
{errors.clientName && <span className="error-message">{errors.clientName}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="location">프로젝트 위치</label>
|
||||
<input
|
||||
type="text"
|
||||
id="location"
|
||||
name="location"
|
||||
value={formData.location}
|
||||
onChange={handleInputChange}
|
||||
placeholder="예: 울산광역시 남구"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="projectType">프로젝트 유형</label>
|
||||
<div className="project-type-container">
|
||||
<select
|
||||
id="projectType"
|
||||
name="projectType"
|
||||
value={formData.projectType}
|
||||
onChange={handleInputChange}
|
||||
>
|
||||
{projectTypes.map(type => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="project-type-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="add-type-btn"
|
||||
onClick={() => setShowAddProjectType(true)}
|
||||
title="프로젝트 유형 추가"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
{projectTypes.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="remove-type-btn"
|
||||
onClick={() => removeProjectType(formData.projectType)}
|
||||
title="현재 선택된 유형 삭제"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddProjectType && (
|
||||
<div className="add-project-type-form">
|
||||
<input
|
||||
type="text"
|
||||
value={newProjectType}
|
||||
onChange={(e) => setNewProjectType(e.target.value)}
|
||||
placeholder="새 프로젝트 유형 입력"
|
||||
onKeyPress={(e) => e.key === 'Enter' && addProjectType()}
|
||||
/>
|
||||
<button type="button" onClick={addProjectType}>추가</button>
|
||||
<button type="button" onClick={() => setShowAddProjectType(false)}>취소</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="status">프로젝트 상태</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleInputChange}
|
||||
>
|
||||
{statusOptions.map(status => (
|
||||
<option key={status.value} value={status.value}>
|
||||
{status.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="contractDate">수주일</label>
|
||||
<input
|
||||
type="date"
|
||||
id="contractDate"
|
||||
name="contractDate"
|
||||
value={formData.contractDate}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="deliveryDate">납기일</label>
|
||||
<input
|
||||
type="date"
|
||||
id="deliveryDate"
|
||||
name="deliveryDate"
|
||||
value={formData.deliveryDate}
|
||||
onChange={handleInputChange}
|
||||
className={errors.deliveryDate ? 'error' : ''}
|
||||
/>
|
||||
{errors.deliveryDate && <span className="error-message">{errors.deliveryDate}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="deliveryMethod">납품 방법</label>
|
||||
<select
|
||||
id="deliveryMethod"
|
||||
name="deliveryMethod"
|
||||
value={formData.deliveryMethod}
|
||||
onChange={handleInputChange}
|
||||
>
|
||||
<option value="">납품 방법 선택</option>
|
||||
<option value="FOB">FOB (Free On Board)</option>
|
||||
<option value="CIF">CIF (Cost, Insurance and Freight)</option>
|
||||
<option value="EXW">EXW (Ex Works)</option>
|
||||
<option value="DDP">DDP (Delivered Duty Paid)</option>
|
||||
<option value="직접납품">직접납품</option>
|
||||
<option value="택배">택배</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group full-width">
|
||||
<label htmlFor="description">프로젝트 설명</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleInputChange}
|
||||
placeholder="프로젝트에 대한 상세 설명을 입력하세요"
|
||||
rows="4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="cancel-button"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="submit-button"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '등록 중...' : '프로젝트 등록'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobRegistrationPage;
|
||||
@@ -1,15 +1,12 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Typography, FormControl, InputLabel, Select, MenuItem, Button, CircularProgress, Alert } from '@mui/material';
|
||||
import { fetchJobs } from '../api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const JobSelectionPage = () => {
|
||||
const JobSelectionPage = ({ onJobSelect }) => {
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [selectedJobNo, setSelectedJobNo] = useState('');
|
||||
const [selectedJobName, setSelectedJobName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadJobs() {
|
||||
@@ -39,47 +36,123 @@ const JobSelectionPage = () => {
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedJobNo && selectedJobName) {
|
||||
navigate(`/bom-status?job_no=${selectedJobNo}&job_name=${encodeURIComponent(selectedJobName)}`);
|
||||
if (selectedJobNo && selectedJobName && onJobSelect) {
|
||||
onJobSelect(selectedJobNo, selectedJobName);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 500, mx: 'auto', mt: 8 }}>
|
||||
<Typography variant="h4" gutterBottom>프로젝트 선택</Typography>
|
||||
{loading && <CircularProgress sx={{ mt: 4 }} />}
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<FormControl size="small" fullWidth sx={{ mt: 3 }}>
|
||||
<InputLabel>프로젝트</InputLabel>
|
||||
<Select
|
||||
value={selectedJobNo}
|
||||
label="프로젝트"
|
||||
onChange={handleSelect}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">선택</MenuItem>
|
||||
{jobs.map(job => (
|
||||
<MenuItem key={job.job_no} value={job.job_no}>
|
||||
{job.job_no} ({job.job_name})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{selectedJobNo && (
|
||||
<Alert severity="info" sx={{ mt: 3 }}>
|
||||
선택된 프로젝트: <b>{selectedJobNo} ({selectedJobName})</b>
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ mt: 4, minWidth: 120 }}
|
||||
disabled={!selectedJobNo}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</Box>
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '600px', margin: '0 auto' }}>
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
📋 프로젝트 선택
|
||||
</h1>
|
||||
<p style={{
|
||||
color: '#718096',
|
||||
fontSize: '16px',
|
||||
margin: '0 0 32px 0'
|
||||
}}>
|
||||
BOM 관리할 프로젝트를 선택하세요.
|
||||
</p>
|
||||
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
로딩 중...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fed7d7',
|
||||
border: '1px solid #fc8181',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
marginBottom: '20px',
|
||||
color: '#c53030'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: '24px'
|
||||
}}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
프로젝트 선택
|
||||
</label>
|
||||
<select
|
||||
value={selectedJobNo}
|
||||
onChange={handleSelect}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
background: 'white',
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
<option value="">프로젝트를 선택하세요</option>
|
||||
{jobs.map(job => (
|
||||
<option key={job.job_no} value={job.job_no}>
|
||||
{job.job_no} - {job.job_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{selectedJobNo && selectedJobName && (
|
||||
<div style={{
|
||||
background: '#c6f6d5',
|
||||
border: '1px solid #68d391',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
marginBottom: '20px',
|
||||
color: '#2f855a'
|
||||
}}>
|
||||
선택된 프로젝트: <strong>{selectedJobNo} - {selectedJobName}</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedJobNo}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 24px',
|
||||
background: selectedJobNo ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : '#e2e8f0',
|
||||
color: selectedJobNo ? 'white' : '#a0aec0',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: selectedJobNo ? 'pointer' : 'not-allowed',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobSelectionPage;
|
||||
export default JobSelectionPage;
|
||||
219
frontend/src/pages/LoginPage.css
Normal file
219
frontend/src/pages/LoginPage.css
Normal file
@@ -0,0 +1,219 @@
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
animation: slideUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
color: #2d3748;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: #718096;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
color: #2d3748;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s ease;
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
background: white;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #fed7d7;
|
||||
border: 1px solid #feb2b2;
|
||||
border-radius: 8px;
|
||||
color: #c53030;
|
||||
font-size: 14px;
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
75% { transform: translateX(5px); }
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
padding: 14px 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.login-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.login-button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.login-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid transparent;
|
||||
border-top: 2px solid white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-footer p {
|
||||
color: #718096;
|
||||
font-size: 14px;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.system-info small {
|
||||
color: #a0aec0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 480px) {
|
||||
.login-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 다크모드 지원 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.login-card {
|
||||
background: #1a202c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
background: #2d3748;
|
||||
border-color: #4a5568;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
background: #2d3748;
|
||||
border-color: #667eea;
|
||||
}
|
||||
}
|
||||
116
frontend/src/pages/LoginPage.jsx
Normal file
116
frontend/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import './LoginPage.css';
|
||||
|
||||
const LoginPage = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
// 입력 시 에러 메시지 초기화
|
||||
if (error) setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.username || !formData.password) {
|
||||
setError('사용자명과 비밀번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await login(formData.username, formData.password);
|
||||
} catch (err) {
|
||||
setError(err.message || '로그인에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<h1>🚀 TK-MP System</h1>
|
||||
<p>통합 프로젝트 관리 시스템</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">사용자명</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
placeholder="사용자명을 입력하세요"
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<span className="error-icon">⚠️</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="login-button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<span className="loading-spinner"></span>
|
||||
로그인 중...
|
||||
</>
|
||||
) : (
|
||||
'로그인'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="login-footer">
|
||||
<p>계정이 없으신가요? 관리자에게 문의하세요.</p>
|
||||
<div className="system-info">
|
||||
<small>TK-MP Project Management System v2.0</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
215
frontend/src/pages/MainPage.css
Normal file
215
frontend/src/pages/MainPage.css
Normal file
@@ -0,0 +1,215 @@
|
||||
.main-page {
|
||||
min-height: 100vh;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
text-align: center;
|
||||
padding: 60px 40px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.main-header h1 {
|
||||
font-size: 2.25rem;
|
||||
color: #1a202c;
|
||||
margin: 0 0 12px 0;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.main-header p {
|
||||
font-size: 1rem;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.banner-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.main-banner {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 32px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.main-banner:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08);
|
||||
border-color: #cbd5e0;
|
||||
}
|
||||
|
||||
.job-registration-banner:hover {
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.bom-management-banner:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
flex-shrink: 0;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.job-registration-banner .banner-icon {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.bom-management-banner .banner-icon {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.banner-content h2 {
|
||||
font-size: 1.25rem;
|
||||
color: #1a202c;
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.banner-content p {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.banner-action {
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.job-registration-banner .banner-action {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.bom-management-banner .banner-action {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.feature-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 48px;
|
||||
padding-top: 48px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
text-align: left;
|
||||
padding: 24px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.feature-item h3 {
|
||||
font-size: 1rem;
|
||||
color: #1a202c;
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feature-item p {
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.main-footer {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.main-footer p {
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 모바일 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.main-page {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.main-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.banner-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.main-banner {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 25px 20px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.feature-info {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
}
|
||||
85
frontend/src/pages/MainPage.jsx
Normal file
85
frontend/src/pages/MainPage.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import './MainPage.css';
|
||||
|
||||
const MainPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="main-page">
|
||||
<div className="main-container">
|
||||
<header className="main-header">
|
||||
<h1>TK Material Planning System</h1>
|
||||
<p>자재 계획 및 BOM 관리 시스템</p>
|
||||
</header>
|
||||
|
||||
<div className="main-content">
|
||||
<div className="banner-container">
|
||||
<div
|
||||
className="main-banner job-registration-banner"
|
||||
onClick={() => navigate('/job-registration')}
|
||||
>
|
||||
<div className="banner-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14,2 14,8 20,8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="banner-content">
|
||||
<h2>기본정보 등록</h2>
|
||||
<p>새로운 프로젝트의 Job No. 및 기본 정보를 등록합니다</p>
|
||||
<div className="banner-action">등록하기 →</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="main-banner bom-management-banner"
|
||||
onClick={() => navigate('/project-selection')}
|
||||
>
|
||||
<div className="banner-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2"/>
|
||||
<path d="M7 2v4"/>
|
||||
<path d="M17 2v4"/>
|
||||
<path d="M14 12h.01"/>
|
||||
<path d="M10 12h.01"/>
|
||||
<path d="M16 16h.01"/>
|
||||
<path d="M12 16h.01"/>
|
||||
<path d="M8 16h.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="banner-content">
|
||||
<h2>BOM 관리</h2>
|
||||
<p>기존 프로젝트의 BOM 자료를 관리하고 분석합니다</p>
|
||||
<div className="banner-action">관리하기 →</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="feature-info">
|
||||
<div className="feature-item">
|
||||
<h3>📊 자재 분석</h3>
|
||||
<p>엑셀 파일 업로드를 통한 자동 자재 분류 및 분석</p>
|
||||
</div>
|
||||
<div className="feature-item">
|
||||
<h3>💰 구매 최적화</h3>
|
||||
<p>리비전별 자재 비교 및 구매 확정 관리</p>
|
||||
</div>
|
||||
<div className="feature-item">
|
||||
<h3>🔧 Tubing 관리</h3>
|
||||
<p>제조사별 튜빙 규격 및 품목번호 통합 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="main-footer">
|
||||
<p>© 2025 Technical Korea. All rights reserved.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainPage;
|
||||
486
frontend/src/pages/MaterialsManagementPage.jsx
Normal file
486
frontend/src/pages/MaterialsManagementPage.jsx
Normal file
@@ -0,0 +1,486 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import MaterialList from '../components/MaterialList';
|
||||
import { fetchMaterials } from '../api';
|
||||
|
||||
const MaterialsManagementPage = ({ user }) => {
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [filteredMaterials, setFilteredMaterials] = useState([]);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
project: '',
|
||||
category: '',
|
||||
status: '',
|
||||
search: ''
|
||||
});
|
||||
const [stats, setStats] = useState({
|
||||
totalMaterials: 0,
|
||||
categorizedMaterials: 0,
|
||||
uncategorizedMaterials: 0,
|
||||
categories: {}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
loadAllMaterials();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyFilters();
|
||||
}, [materials, filters]);
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/jobs/');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setProjects(data.jobs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로딩 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAllMaterials = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 기존 API 함수 사용 - 모든 자재 데이터 로딩
|
||||
const response = await fetchMaterials({ limit: 10000 }); // 충분히 큰 limit
|
||||
const materialsData = response.data?.materials || [];
|
||||
|
||||
setMaterials(materialsData);
|
||||
calculateStats(materialsData);
|
||||
} catch (error) {
|
||||
console.error('자재 데이터 로딩 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateStats = (materialsData) => {
|
||||
const totalMaterials = materialsData.length;
|
||||
const categorizedMaterials = materialsData.filter(m => m.classified_category && m.classified_category !== 'Unknown').length;
|
||||
const uncategorizedMaterials = totalMaterials - categorizedMaterials;
|
||||
|
||||
// 카테고리별 통계
|
||||
const categories = {};
|
||||
materialsData.forEach(material => {
|
||||
const category = material.classified_category || 'Unknown';
|
||||
categories[category] = (categories[category] || 0) + 1;
|
||||
});
|
||||
|
||||
setStats({
|
||||
totalMaterials,
|
||||
categorizedMaterials,
|
||||
uncategorizedMaterials,
|
||||
categories
|
||||
});
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
let filtered = [...materials];
|
||||
|
||||
// 프로젝트 필터
|
||||
if (filters.project) {
|
||||
filtered = filtered.filter(m => m.job_no === filters.project);
|
||||
}
|
||||
|
||||
// 카테고리 필터
|
||||
if (filters.category) {
|
||||
filtered = filtered.filter(m => m.classified_category === filters.category);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (filters.status) {
|
||||
if (filters.status === 'categorized') {
|
||||
filtered = filtered.filter(m => m.classified_category && m.classified_category !== 'Unknown');
|
||||
} else if (filters.status === 'uncategorized') {
|
||||
filtered = filtered.filter(m => !m.classified_category || m.classified_category === 'Unknown');
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (filters.search) {
|
||||
const searchTerm = filters.search.toLowerCase();
|
||||
filtered = filtered.filter(m =>
|
||||
(m.original_description && m.original_description.toLowerCase().includes(searchTerm)) ||
|
||||
(m.size_spec && m.size_spec.toLowerCase().includes(searchTerm)) ||
|
||||
(m.classified_category && m.classified_category.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredMaterials(filtered);
|
||||
};
|
||||
|
||||
const handleFilterChange = (filterType, value) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[filterType]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters({
|
||||
project: '',
|
||||
category: '',
|
||||
status: '',
|
||||
search: ''
|
||||
});
|
||||
};
|
||||
|
||||
const StatCard = ({ title, value, icon, color = '#667eea', subtitle }) => (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '10px',
|
||||
background: color + '20',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px'
|
||||
}}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
{value.toLocaleString()}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#718096',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
📦 자재 관리
|
||||
</h1>
|
||||
<p style={{
|
||||
color: '#718096',
|
||||
fontSize: '16px',
|
||||
margin: '0'
|
||||
}}>
|
||||
전체 프로젝트의 자재 정보를 통합 관리하고 분석하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드들 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
||||
gap: '20px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<StatCard
|
||||
title="전체 자재"
|
||||
value={stats.totalMaterials}
|
||||
icon="📦"
|
||||
color="#667eea"
|
||||
subtitle={`${projects.length}개 프로젝트`}
|
||||
/>
|
||||
<StatCard
|
||||
title="분류 완료"
|
||||
value={stats.categorizedMaterials}
|
||||
icon="✅"
|
||||
color="#48bb78"
|
||||
subtitle={`${Math.round((stats.categorizedMaterials / stats.totalMaterials) * 100) || 0}% 완료`}
|
||||
/>
|
||||
<StatCard
|
||||
title="미분류"
|
||||
value={stats.uncategorizedMaterials}
|
||||
icon="⚠️"
|
||||
color="#ed8936"
|
||||
subtitle="분류 작업 필요"
|
||||
/>
|
||||
<StatCard
|
||||
title="카테고리"
|
||||
value={Object.keys(stats.categories).length}
|
||||
icon="🏷️"
|
||||
color="#9f7aea"
|
||||
subtitle="자재 분류 유형"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 필터 섹션 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: '24px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0'
|
||||
}}>
|
||||
🔍 필터 및 검색
|
||||
</h3>
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#e2e8f0',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#4a5568'
|
||||
}}
|
||||
>
|
||||
필터 초기화
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '16px'
|
||||
}}>
|
||||
{/* 프로젝트 필터 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
프로젝트
|
||||
</label>
|
||||
<select
|
||||
value={filters.project}
|
||||
onChange={(e) => handleFilterChange('project', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value="">전체 프로젝트</option>
|
||||
{projects.map(project => (
|
||||
<option key={project.job_no} value={project.job_no}>
|
||||
{project.job_no} - {project.job_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
카테고리
|
||||
</label>
|
||||
<select
|
||||
value={filters.category}
|
||||
onChange={(e) => handleFilterChange('category', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value="">전체 카테고리</option>
|
||||
{Object.keys(stats.categories).map(category => (
|
||||
<option key={category} value={category}>
|
||||
{category} ({stats.categories[category]})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
분류 상태
|
||||
</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="categorized">분류 완료</option>
|
||||
<option value="uncategorized">미분류</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
검색
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="자재명, 코드, 카테고리 검색..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 결과 요약 */}
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
background: '#f7fafc',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
color: '#4a5568'
|
||||
}}>
|
||||
<strong>{filteredMaterials.length.toLocaleString()}</strong>개의 자재가 검색되었습니다.
|
||||
{filters.project && ` (프로젝트: ${filters.project})`}
|
||||
{filters.category && ` (카테고리: ${filters.category})`}
|
||||
{filters.status && ` (상태: ${filters.status === 'categorized' ? '분류완료' : '미분류'})`}
|
||||
{filters.search && ` (검색: "${filters.search}")`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자재 목록 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
background: '#f7fafc',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: '0',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
자재 목록 ({filteredMaterials.length.toLocaleString()}개)
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#667eea',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
📊 분석 리포트
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#48bb78',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
📤 Excel 내보내기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MaterialList
|
||||
selectedProject={null} // 전체 자재 보기
|
||||
showProjectInfo={true}
|
||||
enableSelection={true}
|
||||
key="all-materials" // 전체 자재 모드
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialsManagementPage;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,71 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Typography, FormControl, InputLabel, Select, MenuItem, CircularProgress, Alert, Button } from '@mui/material';
|
||||
import { fetchJobs } from '../api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const ProjectSelectionPage = () => {
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [selectedJob, setSelectedJob] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadJobs() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetchJobs({});
|
||||
if (res.data && Array.isArray(res.data.jobs)) {
|
||||
setJobs(res.data.jobs);
|
||||
} else {
|
||||
setJobs([]);
|
||||
}
|
||||
} catch (e) {
|
||||
setError('프로젝트 목록을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadJobs();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 500, mx: 'auto', mt: 8 }}>
|
||||
<Typography variant="h4" gutterBottom>프로젝트(Job No) 선택</Typography>
|
||||
{loading && <CircularProgress sx={{ mt: 4 }} />}
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<FormControl size="small" fullWidth sx={{ mt: 3 }}>
|
||||
<InputLabel>Job No</InputLabel>
|
||||
<Select
|
||||
value={selectedJob}
|
||||
label="Job No"
|
||||
onChange={e => setSelectedJob(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">선택</MenuItem>
|
||||
{jobs.map(job => (
|
||||
<MenuItem key={job.job_no} value={job.job_no}>
|
||||
{job.job_no} ({job.job_name})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{selectedJob && (
|
||||
<Alert severity="info" sx={{ mt: 3 }}>
|
||||
선택된 Job No: <b>{selectedJob}</b>
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ mt: 4, minWidth: 120 }}
|
||||
disabled={!selectedJob}
|
||||
onClick={() => navigate(`/bom-status?job_no=${selectedJob}`)}
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectSelectionPage;
|
||||
388
frontend/src/pages/ProjectsPage.jsx
Normal file
388
frontend/src/pages/ProjectsPage.jsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const ProjectsPage = ({ user }) => {
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 실제로는 API에서 프로젝트 데이터를 가져올 예정
|
||||
// 현재는 더미 데이터 사용
|
||||
setTimeout(() => {
|
||||
setProjects([
|
||||
{
|
||||
id: 1,
|
||||
name: '냉동기 시스템 개발',
|
||||
type: '냉동기',
|
||||
status: '진행중',
|
||||
startDate: '2024-01-15',
|
||||
endDate: '2024-06-30',
|
||||
deliveryMethod: 'FOB',
|
||||
progress: 65,
|
||||
manager: '김철수'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'BOG 처리 시스템',
|
||||
type: 'BOG',
|
||||
status: '계획',
|
||||
startDate: '2024-02-01',
|
||||
endDate: '2024-08-15',
|
||||
deliveryMethod: 'CIF',
|
||||
progress: 15,
|
||||
manager: '이영희'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '다이아프람 펌프 제작',
|
||||
type: '다이아프람',
|
||||
status: '완료',
|
||||
startDate: '2023-10-01',
|
||||
endDate: '2024-01-31',
|
||||
deliveryMethod: 'FOB',
|
||||
progress: 100,
|
||||
manager: '박민수'
|
||||
}
|
||||
]);
|
||||
setLoading(false);
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
'계획': '#ed8936',
|
||||
'진행중': '#48bb78',
|
||||
'완료': '#38b2ac',
|
||||
'보류': '#e53e3e'
|
||||
};
|
||||
return colors[status] || '#718096';
|
||||
};
|
||||
|
||||
const getProgressColor = (progress) => {
|
||||
if (progress >= 80) return '#48bb78';
|
||||
if (progress >= 50) return '#ed8936';
|
||||
return '#e53e3e';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '400px',
|
||||
fontSize: '16px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
프로젝트 목록을 불러오는 중...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<div>
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
📋 프로젝트 관리
|
||||
</h1>
|
||||
<p style={{
|
||||
color: '#718096',
|
||||
fontSize: '16px',
|
||||
margin: '0'
|
||||
}}>
|
||||
전체 프로젝트를 관리하고 진행 상황을 확인하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
transition: 'transform 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.transform = 'translateY(-1px)'}
|
||||
onMouseLeave={(e) => e.target.style.transform = 'translateY(0)'}
|
||||
>
|
||||
<span>➕</span>
|
||||
새 프로젝트
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 통계 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '16px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
{[
|
||||
{ label: '전체', count: projects.length, color: '#667eea' },
|
||||
{ label: '진행중', count: projects.filter(p => p.status === '진행중').length, color: '#48bb78' },
|
||||
{ label: '완료', count: projects.filter(p => p.status === '완료').length, color: '#38b2ac' },
|
||||
{ label: '계획', count: projects.filter(p => p.status === '계획').length, color: '#ed8936' }
|
||||
].map((stat, index) => (
|
||||
<div key={index} style={{
|
||||
background: 'white',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: stat.color,
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{stat.count}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
{stat.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 목록 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
background: '#f7fafc'
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: '0',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
프로젝트 목록 ({projects.length}개)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse'
|
||||
}}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f7fafc' }}>
|
||||
{['프로젝트명', '유형', '상태', '수주일', '납기일', '납품방법', '진행률', '담당자'].map(header => (
|
||||
<th key={header} style={{
|
||||
padding: '12px 16px',
|
||||
textAlign: 'left',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
borderBottom: '1px solid #e2e8f0'
|
||||
}}>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map(project => (
|
||||
<tr key={project.id} style={{
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
transition: 'background 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = '#f7fafc'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}>
|
||||
<td style={{ padding: '16px', fontWeight: '600', color: '#2d3748' }}>
|
||||
{project.name}
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
background: '#edf2f7',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
color: '#4a5568'
|
||||
}}>
|
||||
{project.type}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
background: getStatusColor(project.status) + '20',
|
||||
color: getStatusColor(project.status),
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{project.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '16px', color: '#4a5568' }}>
|
||||
{project.startDate}
|
||||
</td>
|
||||
<td style={{ padding: '16px', color: '#4a5568' }}>
|
||||
{project.endDate}
|
||||
</td>
|
||||
<td style={{ padding: '16px', color: '#4a5568' }}>
|
||||
{project.deliveryMethod}
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
height: '6px',
|
||||
background: '#e2e8f0',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${project.progress}%`,
|
||||
height: '100%',
|
||||
background: getProgressColor(project.progress),
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: getProgressColor(project.progress),
|
||||
minWidth: '35px'
|
||||
}}>
|
||||
{project.progress}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px', color: '#4a5568' }}>
|
||||
{project.manager}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트가 없을 때 */}
|
||||
{projects.length === 0 && (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: '60px 40px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📋</div>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
등록된 프로젝트가 없습니다
|
||||
</h3>
|
||||
<p style={{
|
||||
color: '#718096',
|
||||
margin: '0 0 24px 0'
|
||||
}}>
|
||||
첫 번째 프로젝트를 등록해보세요.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
새 프로젝트 등록
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 생성 폼 모달 (향후 구현) */}
|
||||
{showCreateForm && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '32px',
|
||||
maxWidth: '500px',
|
||||
width: '90%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0' }}>새 프로젝트 등록</h3>
|
||||
<p style={{ color: '#718096', margin: '0 0 24px 0' }}>
|
||||
이 기능은 곧 구현될 예정입니다.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#e2e8f0',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectsPage;
|
||||
742
frontend/src/pages/SimpleMaterialsPage.jsx
Normal file
742
frontend/src/pages/SimpleMaterialsPage.jsx
Normal file
@@ -0,0 +1,742 @@
|
||||
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;
|
||||
430
frontend/src/pages/UserManagementPage.css
Normal file
430
frontend/src/pages/UserManagementPage.css
Normal file
@@ -0,0 +1,430 @@
|
||||
.user-management-page {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
color: #2d3748;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: #718096;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.create-user-btn {
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(135deg, #48bb78 0%, #38a169 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.create-user-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(72, 187, 120, 0.3);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fed7d7;
|
||||
border: 1px solid #feb2b2;
|
||||
border-radius: 8px;
|
||||
color: #c53030;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.close-error {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #c53030;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.access-denied {
|
||||
text-align: center;
|
||||
padding: 64px 24px;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.access-denied h2 {
|
||||
color: #2d3748;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 모달 스타일 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.user-form-modal {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 32px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
color: #2d3748;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-modal {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
color: #718096;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.close-modal:hover {
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
/* 폼 스타일 */
|
||||
.user-form-modal form {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
color: #2d3748;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.checkbox-group input {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 권한 설정 섹션 */
|
||||
.permissions-section {
|
||||
margin-bottom: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.permissions-section h3 {
|
||||
color: #2d3748;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.permissions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.permission-category {
|
||||
background: #f7fafc;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.permission-category h4 {
|
||||
color: #2d3748;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px 0;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.permission-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
font-size: 14px;
|
||||
color: #4a5568;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.permission-item input {
|
||||
margin: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.permission-item:hover {
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
/* 폼 액션 버튼 */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 12px 32px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
padding: 12px 32px;
|
||||
background: #e2e8f0;
|
||||
color: #4a5568;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: #cbd5e0;
|
||||
}
|
||||
|
||||
/* 사용자 테이블 */
|
||||
.users-list {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.users-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
background: #f7fafc;
|
||||
color: #2d3748;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
padding: 16px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.users-table td {
|
||||
padding: 16px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 14px;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.users-table tr:hover {
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
/* 배지 스타일 */
|
||||
.role-badge,
|
||||
.access-badge,
|
||||
.status-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.role-admin { background: #fed7d7; color: #c53030; }
|
||||
.role-system { background: #fbb6ce; color: #b83280; }
|
||||
.role-leader { background: #bee3f8; color: #2b6cb0; }
|
||||
.role-support { background: #c6f6d5; color: #2f855a; }
|
||||
.role-user { background: #e2e8f0; color: #4a5568; }
|
||||
|
||||
.access-manager { background: #fed7d7; color: #c53030; }
|
||||
.access-leader { background: #bee3f8; color: #2b6cb0; }
|
||||
.access-worker { background: #c6f6d5; color: #2f855a; }
|
||||
.access-viewer { background: #faf089; color: #744210; }
|
||||
|
||||
.status-badge.active { background: #c6f6d5; color: #2f855a; }
|
||||
.status-badge.inactive { background: #fed7d7; color: #c53030; }
|
||||
|
||||
/* 액션 버튼 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.edit-btn,
|
||||
.toggle-btn {
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: #bee3f8;
|
||||
color: #2b6cb0;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: #90cdf4;
|
||||
}
|
||||
|
||||
.toggle-btn.deactivate {
|
||||
background: #fed7d7;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.toggle-btn.activate {
|
||||
background: #c6f6d5;
|
||||
color: #2f855a;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 64px 24px;
|
||||
color: #718096;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.user-management-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.permissions-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.users-table th,
|
||||
.users-table td {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.submit-btn,
|
||||
.cancel-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
499
frontend/src/pages/UserManagementPage.jsx
Normal file
499
frontend/src/pages/UserManagementPage.jsx
Normal file
@@ -0,0 +1,499 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import api from '../api';
|
||||
import './UserManagementPage.css';
|
||||
|
||||
const UserManagementPage = () => {
|
||||
const { user, hasPermission, isAdmin } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'user',
|
||||
access_level: 'worker',
|
||||
department: '',
|
||||
position: '',
|
||||
phone: '',
|
||||
is_active: true,
|
||||
permissions: []
|
||||
});
|
||||
|
||||
// 권한 목록 정의
|
||||
const availablePermissions = [
|
||||
{ id: 'bom.view', name: 'BOM 조회', category: 'BOM' },
|
||||
{ id: 'bom.edit', name: 'BOM 편집', category: 'BOM' },
|
||||
{ id: 'bom.delete', name: 'BOM 삭제', category: 'BOM' },
|
||||
{ id: 'project.view', name: '프로젝트 조회', category: '프로젝트' },
|
||||
{ id: 'project.create', name: '프로젝트 생성', category: '프로젝트' },
|
||||
{ id: 'project.edit', name: '프로젝트 편집', category: '프로젝트' },
|
||||
{ id: 'project.delete', name: '프로젝트 삭제', category: '프로젝트' },
|
||||
{ id: 'file.upload', name: '파일 업로드', category: '파일' },
|
||||
{ id: 'file.download', name: '파일 다운로드', category: '파일' },
|
||||
{ id: 'file.delete', name: '파일 삭제', category: '파일' },
|
||||
{ id: 'user.view', name: '사용자 조회', category: '사용자' },
|
||||
{ id: 'user.create', name: '사용자 생성', category: '사용자' },
|
||||
{ id: 'user.edit', name: '사용자 편집', category: '사용자' },
|
||||
{ id: 'user.delete', name: '사용자 삭제', category: '사용자' },
|
||||
{ id: 'system.admin', name: '시스템 관리', category: '시스템' }
|
||||
];
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'admin', label: '관리자', description: '모든 권한' },
|
||||
{ value: 'system', label: '시스템', description: '시스템 관리' },
|
||||
{ value: 'leader', label: '팀장', description: '팀 관리' },
|
||||
{ value: 'support', label: '지원', description: '지원 업무' },
|
||||
{ value: 'user', label: '사용자', description: '일반 사용자' }
|
||||
];
|
||||
|
||||
const accessLevelOptions = [
|
||||
{ value: 'manager', label: '관리자', description: '전체 관리 권한' },
|
||||
{ value: 'leader', label: '팀장', description: '팀 관리 권한' },
|
||||
{ value: 'worker', label: '작업자', description: '기본 작업 권한' },
|
||||
{ value: 'viewer', label: '조회자', description: '조회 전용' }
|
||||
];
|
||||
|
||||
// 사용자 목록 조회
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await api.get('/auth/users');
|
||||
if (response.data.success) {
|
||||
setUsers(response.data.users);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
setError('사용자 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPermission('user.view') || isAdmin()) {
|
||||
fetchUsers();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 폼 데이터 변경 처리
|
||||
const handleFormChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
// 권한 변경 처리
|
||||
const handlePermissionChange = (permissionId, checked) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
permissions: checked
|
||||
? [...prev.permissions, permissionId]
|
||||
: prev.permissions.filter(p => p !== permissionId)
|
||||
}));
|
||||
};
|
||||
|
||||
// 사용자 생성
|
||||
const handleCreateUser = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await api.post('/auth/register', formData);
|
||||
if (response.data.success) {
|
||||
setShowCreateForm(false);
|
||||
setFormData({
|
||||
username: '',
|
||||
password: '',
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'user',
|
||||
access_level: 'worker',
|
||||
department: '',
|
||||
position: '',
|
||||
phone: '',
|
||||
is_active: true,
|
||||
permissions: []
|
||||
});
|
||||
await fetchUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create user:', error);
|
||||
setError(error.response?.data?.error?.message || '사용자 생성에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 사용자 편집
|
||||
const handleEditUser = (userData) => {
|
||||
setEditingUser(userData.user_id);
|
||||
setFormData({
|
||||
username: userData.username,
|
||||
password: '',
|
||||
name: userData.name,
|
||||
email: userData.email,
|
||||
role: userData.role,
|
||||
access_level: userData.access_level,
|
||||
department: userData.department || '',
|
||||
position: userData.position || '',
|
||||
phone: userData.phone || '',
|
||||
is_active: userData.is_active,
|
||||
permissions: userData.permissions || []
|
||||
});
|
||||
setShowCreateForm(true);
|
||||
};
|
||||
|
||||
// 사용자 업데이트
|
||||
const handleUpdateUser = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const updateData = { ...formData };
|
||||
if (!updateData.password) {
|
||||
delete updateData.password; // 비밀번호가 비어있으면 제외
|
||||
}
|
||||
|
||||
const response = await api.put(`/auth/users/${editingUser}`, updateData);
|
||||
if (response.data.success) {
|
||||
setShowCreateForm(false);
|
||||
setEditingUser(null);
|
||||
setFormData({
|
||||
username: '',
|
||||
password: '',
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'user',
|
||||
access_level: 'worker',
|
||||
department: '',
|
||||
position: '',
|
||||
phone: '',
|
||||
is_active: true,
|
||||
permissions: []
|
||||
});
|
||||
await fetchUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update user:', error);
|
||||
setError(error.response?.data?.error?.message || '사용자 수정에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 사용자 활성화/비활성화
|
||||
const handleToggleUserStatus = async (userId, currentStatus) => {
|
||||
try {
|
||||
const response = await api.put(`/auth/users/${userId}`, {
|
||||
is_active: !currentStatus
|
||||
});
|
||||
if (response.data.success) {
|
||||
await fetchUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle user status:', error);
|
||||
setError('사용자 상태 변경에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasPermission('user.view') && !isAdmin()) {
|
||||
return (
|
||||
<div className="access-denied">
|
||||
<h2>접근 권한이 없습니다</h2>
|
||||
<p>사용자 관리 페이지에 접근할 권한이 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="user-management-page">
|
||||
<div className="page-header">
|
||||
<h1>👥 사용자 관리</h1>
|
||||
<p>시스템 사용자 계정을 관리하고 권한을 설정합니다.</p>
|
||||
|
||||
{(hasPermission('user.create') || isAdmin()) && (
|
||||
<button
|
||||
className="create-user-btn"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
>
|
||||
➕ 새 사용자 생성
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<span className="error-icon">⚠️</span>
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="close-error">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 사용자 생성/편집 폼 */}
|
||||
{showCreateForm && (
|
||||
<div className="modal-overlay">
|
||||
<div className="user-form-modal">
|
||||
<div className="modal-header">
|
||||
<h2>{editingUser ? '사용자 편집' : '새 사용자 생성'}</h2>
|
||||
<button
|
||||
className="close-modal"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setEditingUser(null);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={editingUser ? handleUpdateUser : handleCreateUser}>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label>사용자명 *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleFormChange}
|
||||
required
|
||||
disabled={editingUser} // 편집 시 사용자명 변경 불가
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>비밀번호 {!editingUser && '*'}</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleFormChange}
|
||||
required={!editingUser}
|
||||
placeholder={editingUser ? '변경하지 않으려면 비워두세요' : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>이름 *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleFormChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>이메일 *</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleFormChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>역할</label>
|
||||
<select
|
||||
name="role"
|
||||
value={formData.role}
|
||||
onChange={handleFormChange}
|
||||
>
|
||||
{roleOptions.map(role => (
|
||||
<option key={role.value} value={role.value}>
|
||||
{role.label} - {role.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>접근 레벨</label>
|
||||
<select
|
||||
name="access_level"
|
||||
value={formData.access_level}
|
||||
onChange={handleFormChange}
|
||||
>
|
||||
{accessLevelOptions.map(level => (
|
||||
<option key={level.value} value={level.value}>
|
||||
{level.label} - {level.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>부서</label>
|
||||
<input
|
||||
type="text"
|
||||
name="department"
|
||||
value={formData.department}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>직책</label>
|
||||
<input
|
||||
type="text"
|
||||
name="position"
|
||||
value={formData.position}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>전화번호</label>
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group checkbox-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
계정 활성화
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 권한 설정 */}
|
||||
<div className="permissions-section">
|
||||
<h3>권한 설정</h3>
|
||||
<div className="permissions-grid">
|
||||
{Object.entries(
|
||||
availablePermissions.reduce((acc, perm) => {
|
||||
if (!acc[perm.category]) acc[perm.category] = [];
|
||||
acc[perm.category].push(perm);
|
||||
return acc;
|
||||
}, {})
|
||||
).map(([category, perms]) => (
|
||||
<div key={category} className="permission-category">
|
||||
<h4>{category}</h4>
|
||||
{perms.map(perm => (
|
||||
<label key={perm.id} className="permission-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions.includes(perm.id)}
|
||||
onChange={(e) => handlePermissionChange(perm.id, e.target.checked)}
|
||||
/>
|
||||
{perm.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="submit-btn">
|
||||
{editingUser ? '수정' : '생성'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cancel-btn"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setEditingUser(null);
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 사용자 목록 */}
|
||||
<div className="users-list">
|
||||
{isLoading ? (
|
||||
<div className="loading">사용자 목록을 불러오는 중...</div>
|
||||
) : (
|
||||
<div className="users-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>사용자명</th>
|
||||
<th>이름</th>
|
||||
<th>이메일</th>
|
||||
<th>역할</th>
|
||||
<th>접근 레벨</th>
|
||||
<th>부서</th>
|
||||
<th>상태</th>
|
||||
<th>마지막 로그인</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(userData => (
|
||||
<tr key={userData.user_id}>
|
||||
<td>{userData.username}</td>
|
||||
<td>{userData.name}</td>
|
||||
<td>{userData.email}</td>
|
||||
<td>
|
||||
<span className={`role-badge role-${userData.role}`}>
|
||||
{roleOptions.find(r => r.value === userData.role)?.label}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`access-badge access-${userData.access_level}`}>
|
||||
{accessLevelOptions.find(l => l.value === userData.access_level)?.label}
|
||||
</span>
|
||||
</td>
|
||||
<td>{userData.department || '-'}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${userData.is_active ? 'active' : 'inactive'}`}>
|
||||
{userData.is_active ? '활성' : '비활성'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{userData.last_login_at
|
||||
? new Date(userData.last_login_at).toLocaleString('ko-KR')
|
||||
: '없음'
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div className="action-buttons">
|
||||
{(hasPermission('user.edit') || isAdmin()) && (
|
||||
<button
|
||||
className="edit-btn"
|
||||
onClick={() => handleEditUser(userData)}
|
||||
title="편집"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
{(hasPermission('user.edit') || isAdmin()) && (
|
||||
<button
|
||||
className={`toggle-btn ${userData.is_active ? 'deactivate' : 'activate'}`}
|
||||
onClick={() => handleToggleUserStatus(userData.user_id, userData.is_active)}
|
||||
title={userData.is_active ? '비활성화' : '활성화'}
|
||||
>
|
||||
{userData.is_active ? '🔒' : '🔓'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserManagementPage;
|
||||
@@ -21,21 +21,44 @@ const groupMaterialsByCategory = (materials) => {
|
||||
|
||||
/**
|
||||
* 동일한 자재끼리 합치기 (자재 설명 + 사이즈 기준)
|
||||
* 엑셀 내보내기용 특별 처리:
|
||||
* - PIPE: 끝단 정보 제거 (BOE-POE, POE-TOE 등)
|
||||
* - NIPPLE: 길이별 구분 (75mm, 100mm 등)
|
||||
*/
|
||||
const consolidateMaterials = (materials, isComparison = false) => {
|
||||
const consolidated = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
const category = material.classified_category || material.category || 'UNCATEGORIZED';
|
||||
const description = material.original_description || material.description || '';
|
||||
let description = material.original_description || material.description || '';
|
||||
const sizeSpec = material.size_spec || '';
|
||||
|
||||
// 그룹화 키: 카테고리 + 자재설명 + 사이즈
|
||||
const groupKey = `${category}|${description}|${sizeSpec}`;
|
||||
// 파이프 끝단 정보 제거 (엑셀 내보내기용)
|
||||
if (category === 'PIPE') {
|
||||
description = description
|
||||
.replace(/\s+(BOE-POE|POE-TOE|BOE-TOE|BOE-BBE|BBE-POE|BBE-BBE|POE-POE|TOE-TOE)\s*$/i, '')
|
||||
.replace(/\s+(BOE|POE|TOE|BBE)\s*-\s*(BOE|POE|TOE|BBE)\s*$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// 니플의 경우 길이 정보를 그룹화 키에 포함
|
||||
let lengthInfo = '';
|
||||
if (category === 'FITTING' && description.toLowerCase().includes('nipple')) {
|
||||
const lengthMatch = description.match(/(\d+)\s*mm/i);
|
||||
if (lengthMatch) {
|
||||
lengthInfo = `_${lengthMatch[1]}mm`;
|
||||
}
|
||||
}
|
||||
|
||||
// 그룹화 키: 카테고리 + 정제된자재설명 + 사이즈 + 길이정보
|
||||
const groupKey = `${category}|${description}|${sizeSpec}${lengthInfo}`;
|
||||
|
||||
if (!consolidated[groupKey]) {
|
||||
consolidated[groupKey] = {
|
||||
...material,
|
||||
// 정제된 설명으로 덮어쓰기
|
||||
original_description: description,
|
||||
description: description,
|
||||
quantity: 0,
|
||||
totalLength: 0, // 파이프용
|
||||
itemCount: 0, // 파이프 개수
|
||||
@@ -99,12 +122,23 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
const category = material.classified_category || material.category || '-';
|
||||
const isPipe = category === 'PIPE';
|
||||
|
||||
// 엑셀용 자재 설명 정제
|
||||
let cleanDescription = material.original_description || material.description || '-';
|
||||
|
||||
// 파이프 끝단 정보 제거
|
||||
if (category === 'PIPE') {
|
||||
cleanDescription = cleanDescription
|
||||
.replace(/\s+(BOE-POE|POE-TOE|BOE-TOE|BOE-BBE|BBE-POE|BBE-BBE|POE-POE|TOE-TOE)\s*$/i, '')
|
||||
.replace(/\s+(BOE|POE|TOE|BBE)\s*-\s*(BOE|POE|TOE|BBE)\s*$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// 구매 수량 계산
|
||||
const purchaseInfo = calculatePurchaseQuantity(material);
|
||||
|
||||
const base = {
|
||||
'카테고리': category,
|
||||
'자재 설명': material.original_description || material.description || '-',
|
||||
'자재 설명': cleanDescription,
|
||||
'사이즈': material.size_spec || '-'
|
||||
};
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
|
||||
/**
|
||||
* 파이프 구매 수량 계산
|
||||
* @param {number} lengthMm - 파이프 총 길이 (mm)
|
||||
* @param {number} totalLengthMm - 파이프 총 길이 (mm)
|
||||
* @param {number} quantity - BOM 수량 (개수)
|
||||
* @returns {object} 구매 계산 결과
|
||||
*/
|
||||
export const calculatePipePurchase = (lengthMm, quantity) => {
|
||||
if (!lengthMm || lengthMm <= 0 || !quantity || quantity <= 0) {
|
||||
export const calculatePipePurchase = (totalLengthMm, quantity) => {
|
||||
if (!totalLengthMm || totalLengthMm <= 0 || !quantity || quantity <= 0) {
|
||||
return {
|
||||
purchaseQuantity: 0,
|
||||
standardLength: 6000,
|
||||
@@ -18,17 +18,18 @@ export const calculatePipePurchase = (lengthMm, quantity) => {
|
||||
};
|
||||
}
|
||||
|
||||
// 절단 여유분: 조각마다 2mm 추가
|
||||
const cutLength = lengthMm + (quantity * 2);
|
||||
// 절단 여유분: 절단 개수만큼 3mm 추가 (백엔드와 동일)
|
||||
const cuttingLoss = quantity * 3;
|
||||
const requiredLength = totalLengthMm + cuttingLoss;
|
||||
|
||||
// 6,000mm 단위로 올림 계산
|
||||
const pipeCount = Math.ceil(cutLength / 6000);
|
||||
const pipeCount = Math.ceil(requiredLength / 6000);
|
||||
|
||||
return {
|
||||
purchaseQuantity: pipeCount,
|
||||
standardLength: 6000,
|
||||
cutLength: cutLength,
|
||||
calculation: `${lengthMm}mm + ${quantity * 2}mm(여유분) = ${cutLength}mm → ${pipeCount}본`
|
||||
cutLength: requiredLength,
|
||||
calculation: `${totalLengthMm}mm + ${cuttingLoss}mm(절단손실) = ${requiredLength}mm → ${pipeCount}본`
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user