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:
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;
|
||||
Reference in New Issue
Block a user