feat: SWG 가스켓 전체 구성 정보 표시 개선
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:
Hyungi Ahn
2025-08-30 14:23:01 +09:00
parent 78d90c7a8f
commit 4f8e395f87
84 changed files with 16297 additions and 2161 deletions

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

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

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

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

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

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

View File

@@ -0,0 +1,159 @@
import React from 'react';
import { useAuth } from '../contexts/AuthContext';
import LoginPage from '../pages/LoginPage';
const ProtectedRoute = ({
children,
requiredPermission = null,
requiredRole = null,
fallback = null
}) => {
const { isAuthenticated, isLoading, user, hasPermission, hasRole } = useAuth();
// 로딩 중일 때
if (isLoading) {
return (
<div className="loading-container" style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
background: '#f7fafc',
color: '#718096'
}}>
<div className="loading-spinner-large" style={{
width: '48px',
height: '48px',
border: '4px solid #e2e8f0',
borderTop: '4px solid #667eea',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginBottom: '16px'
}}></div>
<p>인증 정보를 확인하는 ...</p>
</div>
);
}
// 인증되지 않은 경우
if (!isAuthenticated) {
return <LoginPage />;
}
// 특정 권한이 필요한 경우
if (requiredPermission && !hasPermission(requiredPermission)) {
return fallback || (
<div className="access-denied-container">
<div className="access-denied-content">
<div className="access-denied-icon">🔒</div>
<h2>접근 권한이 없습니다</h2>
<p> 페이지에 접근하기 위한 권한이 없습니다.</p>
<p className="permission-info">
필요한 권한: <code>{requiredPermission}</code>
</p>
<div className="user-info">
<p>현재 사용자: <strong>{user?.name}</strong> ({user?.username})</p>
<p>역할: <strong>{user?.role}</strong></p>
<p>접근 레벨: <strong>{user?.access_level}</strong></p>
</div>
<button
className="back-button"
onClick={() => window.history.back()}
>
이전 페이지로 돌아가기
</button>
</div>
</div>
);
}
// 특정 역할이 필요한 경우
if (requiredRole && !hasRole(requiredRole)) {
return fallback || (
<div className="access-denied-container">
<div className="access-denied-content">
<div className="access-denied-icon">👤</div>
<h2>역할 권한이 없습니다</h2>
<p> 페이지에 접근하기 위한 역할 권한이 없습니다.</p>
<p className="role-info">
필요한 역할: <code>{requiredRole}</code>
</p>
<div className="user-info">
<p>현재 사용자: <strong>{user?.name}</strong> ({user?.username})</p>
<p>현재 역할: <strong>{user?.role}</strong></p>
</div>
<button
className="back-button"
onClick={() => window.history.back()}
>
이전 페이지로 돌아가기
</button>
</div>
</div>
);
}
// 모든 조건을 만족하는 경우 자식 컴포넌트 렌더링
return children;
};
// 관리자 전용 라우트
export const AdminRoute = ({ children, fallback = null }) => {
return (
<ProtectedRoute
requiredRole="admin"
fallback={fallback}
>
{children}
</ProtectedRoute>
);
};
// 시스템 관리자 전용 라우트
export const SystemRoute = ({ children, fallback = null }) => {
const { hasRole } = useAuth();
if (!hasRole('admin') && !hasRole('system')) {
return fallback || (
<div className="access-denied-container">
<div className="access-denied-content">
<div className="access-denied-icon"></div>
<h2>시스템 관리자 권한이 필요합니다</h2>
<p> 페이지는 시스템 관리자만 접근할 있습니다.</p>
</div>
</div>
);
}
return (
<ProtectedRoute>
{children}
</ProtectedRoute>
);
};
// 매니저 이상 권한 라우트
export const ManagerRoute = ({ children, fallback = null }) => {
const { isManager } = useAuth();
if (!isManager()) {
return fallback || (
<div className="access-denied-container">
<div className="access-denied-content">
<div className="access-denied-icon">👔</div>
<h2>관리자 권한이 필요합니다</h2>
<p> 페이지는 관리자 이상의 권한이 필요합니다.</p>
</div>
</div>
);
}
return (
<ProtectedRoute>
{children}
</ProtectedRoute>
);
};
export default ProtectedRoute;

View File

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

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