feat(tkeg): tkeg BOM 자재관리 서비스 초기 세팅 (api + web + docker-compose)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
184
tkeg/web/src/pages/BOMManagementPage.css
Normal file
184
tkeg/web/src/pages/BOMManagementPage.css
Normal file
@@ -0,0 +1,184 @@
|
||||
/* BOM Management Page Styles */
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.bom-management-page {
|
||||
padding: 40px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.bom-header-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.bom-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.bom-stat-card {
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.bom-stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.bom-stat-number {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.bom-stat-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bom-category-tabs {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 24px 32px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.bom-category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bom-category-button {
|
||||
border-radius: 12px;
|
||||
padding: 16px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.bom-category-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.bom-category-icon {
|
||||
font-size: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bom-category-count {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bom-content-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bom-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
}
|
||||
|
||||
.bom-loading-spinner {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 4px solid #e2e8f0;
|
||||
border-top: 4px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
.bom-loading-text {
|
||||
font-size: 18px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bom-error {
|
||||
padding: 60px;
|
||||
text-align: center;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.bom-error-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.bom-error-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bom-error-message {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.bom-management-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.bom-header-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.bom-stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bom-category-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bom-category-button {
|
||||
padding: 12px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.bom-stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bom-category-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
613
tkeg/web/src/pages/BOMManagementPage.jsx
Normal file
613
tkeg/web/src/pages/BOMManagementPage.jsx
Normal file
@@ -0,0 +1,613 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { fetchMaterials } from '../api';
|
||||
import api from '../api';
|
||||
import {
|
||||
PipeMaterialsView,
|
||||
FittingMaterialsView,
|
||||
FlangeMaterialsView,
|
||||
ValveMaterialsView,
|
||||
GasketMaterialsView,
|
||||
BoltMaterialsView,
|
||||
SupportMaterialsView,
|
||||
SpecialMaterialsView,
|
||||
UnclassifiedMaterialsView
|
||||
} from '../components/bom';
|
||||
import RevisionManagementPanel from '../components/revision/RevisionManagementPanel';
|
||||
import './BOMManagementPage.css';
|
||||
|
||||
const BOMManagementPage = ({
|
||||
onNavigate,
|
||||
selectedProject,
|
||||
fileId,
|
||||
jobNo,
|
||||
bomName,
|
||||
revision,
|
||||
filename,
|
||||
user
|
||||
}) => {
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedCategory, setSelectedCategory] = useState('PIPE');
|
||||
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
|
||||
const [exportHistory, setExportHistory] = useState([]);
|
||||
const [availableRevisions, setAvailableRevisions] = useState([]);
|
||||
const [currentRevision, setCurrentRevision] = useState(revision || 'Rev.0');
|
||||
const [userRequirements, setUserRequirements] = useState({});
|
||||
const [purchasedMaterials, setPurchasedMaterials] = useState(new Set());
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// 리비전 관련 상태
|
||||
const [isRevisionMode, setIsRevisionMode] = useState(false);
|
||||
const [previousFileId, setPreviousFileId] = useState(null);
|
||||
const [showRevisionPanel, setShowRevisionPanel] = useState(false);
|
||||
|
||||
// 자재 업데이트 함수 (브랜드, 사용자 요구사항 등)
|
||||
const updateMaterial = (materialId, updates) => {
|
||||
setMaterials(prevMaterials =>
|
||||
prevMaterials.map(material =>
|
||||
material.id === materialId
|
||||
? { ...material, ...updates }
|
||||
: material
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// 카테고리 정의
|
||||
const categories = [
|
||||
{ key: 'PIPE', label: 'Pipes', color: '#3b82f6' },
|
||||
{ key: 'FITTING', label: 'Fittings', color: '#10b981' },
|
||||
{ key: 'FLANGE', label: 'Flanges', color: '#f59e0b' },
|
||||
{ key: 'VALVE', label: 'Valves', color: '#ef4444' },
|
||||
{ key: 'GASKET', label: 'Gaskets', color: '#8b5cf6' },
|
||||
{ key: 'BOLT', label: 'Bolts', color: '#6b7280' },
|
||||
{ key: 'SUPPORT', label: 'Supports', color: '#f97316' },
|
||||
{ key: 'SPECIAL', label: 'Special Items', color: '#ec4899' },
|
||||
{ key: 'UNCLASSIFIED', label: 'Unclassified', color: '#64748b' }
|
||||
];
|
||||
|
||||
// 자료 로드 함수들
|
||||
const loadMaterials = async (id) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
console.log('🔍 자재 데이터 로딩 중...', {
|
||||
file_id: id,
|
||||
selectedProject: selectedProject?.job_no || selectedProject?.official_project_code,
|
||||
jobNo
|
||||
});
|
||||
|
||||
// 구매신청된 자재 먼저 확인
|
||||
const projectJobNo = selectedProject?.job_no || selectedProject?.official_project_code || jobNo;
|
||||
await loadPurchasedMaterials(projectJobNo);
|
||||
|
||||
const response = await fetchMaterials({
|
||||
file_id: parseInt(id),
|
||||
limit: 10000,
|
||||
exclude_requested: false,
|
||||
job_no: projectJobNo
|
||||
});
|
||||
|
||||
if (response.data?.materials) {
|
||||
const materialsData = response.data.materials;
|
||||
console.log(`✅ ${materialsData.length}개 원본 자재 로드 완료`);
|
||||
setMaterials(materialsData);
|
||||
setError(null);
|
||||
} else {
|
||||
console.warn('⚠️ 자재 데이터가 없습니다:', response.data);
|
||||
setMaterials([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('자재 로드 실패:', error);
|
||||
setError('자재 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAvailableRevisions = async () => {
|
||||
try {
|
||||
const response = await api.get('/files/', {
|
||||
params: { job_no: jobNo }
|
||||
});
|
||||
|
||||
const allFiles = Array.isArray(response.data) ? response.data : response.data?.files || [];
|
||||
const sameBomFiles = allFiles.filter(file =>
|
||||
(file.bom_name || file.original_filename) === bomName
|
||||
);
|
||||
|
||||
sameBomFiles.sort((a, b) => {
|
||||
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
|
||||
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
|
||||
return revB - revA;
|
||||
});
|
||||
|
||||
setAvailableRevisions(sameBomFiles);
|
||||
} catch (error) {
|
||||
console.error('리비전 목록 조회 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPurchasedMaterials = async (jobNo) => {
|
||||
try {
|
||||
// 새로운 API로 구매신청된 자재 ID 목록 조회
|
||||
const response = await api.get('/purchase-request/requested-materials', {
|
||||
params: {
|
||||
job_no: jobNo,
|
||||
file_id: fileId
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data?.requested_material_ids) {
|
||||
const purchasedIds = new Set(response.data.requested_material_ids);
|
||||
setPurchasedMaterials(purchasedIds);
|
||||
console.log(`✅ ${purchasedIds.size}개 구매신청된 자재 ID 로드 완료`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('구매신청 자재 조회 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUserRequirements = async (fileId) => {
|
||||
try {
|
||||
const response = await api.get(`/files/${fileId}/user-requirements`);
|
||||
if (response.data?.requirements) {
|
||||
const reqMap = {};
|
||||
response.data.requirements.forEach(req => {
|
||||
reqMap[req.material_id] = req.requirement;
|
||||
});
|
||||
setUserRequirements(reqMap);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 요구사항 로드 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
if (fileId) {
|
||||
loadMaterials(fileId);
|
||||
loadAvailableRevisions();
|
||||
loadUserRequirements(fileId);
|
||||
checkRevisionMode(); // 리비전 모드 확인
|
||||
}
|
||||
}, [fileId]);
|
||||
|
||||
// 리비전 모드 확인
|
||||
const checkRevisionMode = async () => {
|
||||
try {
|
||||
// 현재 job_no의 모든 파일 목록 확인
|
||||
const response = await api.get(`/files/list?job_no=${jobNo}`);
|
||||
const files = response.data.files || [];
|
||||
|
||||
if (files.length > 1) {
|
||||
// 파일들을 업로드 날짜순으로 정렬
|
||||
const sortedFiles = files.sort((a, b) => new Date(a.upload_date) - new Date(b.upload_date));
|
||||
|
||||
// 현재 파일의 인덱스 찾기
|
||||
const currentIndex = sortedFiles.findIndex(file => file.id === parseInt(fileId));
|
||||
|
||||
if (currentIndex > 0) {
|
||||
// 이전 파일이 있으면 리비전 모드 활성화
|
||||
const previousFile = sortedFiles[currentIndex - 1];
|
||||
setIsRevisionMode(true);
|
||||
setPreviousFileId(previousFile.id);
|
||||
|
||||
console.log('✅ 리비전 모드 활성화:', {
|
||||
currentFileId: fileId,
|
||||
previousFileId: previousFile.id,
|
||||
currentRevision: revision,
|
||||
previousRevision: previousFile.revision
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('리비전 모드 확인 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 리비전 관리 핸들러
|
||||
const handleRevisionComplete = (revisionData) => {
|
||||
console.log('✅ 리비전 완료:', revisionData);
|
||||
setShowRevisionPanel(false);
|
||||
setIsRevisionMode(false);
|
||||
|
||||
// 자재 목록 새로고침
|
||||
loadMaterials(fileId);
|
||||
|
||||
// 성공 메시지 표시
|
||||
alert('리비전 처리가 완료되었습니다!');
|
||||
};
|
||||
|
||||
const handleRevisionCancel = (cancelData) => {
|
||||
console.log('❌ 리비전 취소:', cancelData);
|
||||
setShowRevisionPanel(false);
|
||||
|
||||
// 취소 메시지 표시
|
||||
alert('리비전 처리가 취소되었습니다.');
|
||||
};
|
||||
|
||||
// 자재 로드 후 선택된 카테고리가 유효한지 확인
|
||||
useEffect(() => {
|
||||
if (materials.length > 0) {
|
||||
const availableCategories = categories.filter(category => {
|
||||
const count = getCategoryMaterials(category.key).length;
|
||||
return count > 0;
|
||||
});
|
||||
|
||||
// 현재 선택된 카테고리에 자재가 없으면 첫 번째 유효한 카테고리로 전환
|
||||
const currentCategoryHasMaterials = getCategoryMaterials(selectedCategory).length > 0;
|
||||
if (!currentCategoryHasMaterials && availableCategories.length > 0) {
|
||||
setSelectedCategory(availableCategories[0].key);
|
||||
}
|
||||
}
|
||||
}, [materials, selectedCategory]);
|
||||
|
||||
// 카테고리별 자재 필터링
|
||||
const getCategoryMaterials = (category) => {
|
||||
return materials.filter(material =>
|
||||
material.classified_category === category ||
|
||||
material.category === category
|
||||
);
|
||||
};
|
||||
|
||||
// 카테고리별 컴포넌트 렌더링
|
||||
const renderCategoryView = () => {
|
||||
const categoryMaterials = getCategoryMaterials(selectedCategory);
|
||||
const commonProps = {
|
||||
materials: categoryMaterials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate: (materialIds) => {
|
||||
setPurchasedMaterials(prev => {
|
||||
const newSet = new Set(prev);
|
||||
materialIds.forEach(id => newSet.add(id));
|
||||
console.log(`📦 구매신청 자재 추가: 기존 ${prev.size}개 → 신규 ${newSet.size}개`);
|
||||
return newSet;
|
||||
});
|
||||
},
|
||||
updateMaterial, // 자재 업데이트 함수 추가
|
||||
fileId,
|
||||
jobNo,
|
||||
user,
|
||||
onNavigate
|
||||
};
|
||||
|
||||
switch (selectedCategory) {
|
||||
case 'PIPE':
|
||||
return <PipeMaterialsView {...commonProps} />;
|
||||
case 'FITTING':
|
||||
return <FittingMaterialsView {...commonProps} />;
|
||||
case 'FLANGE':
|
||||
return <FlangeMaterialsView {...commonProps} />;
|
||||
case 'VALVE':
|
||||
return <ValveMaterialsView {...commonProps} />;
|
||||
case 'GASKET':
|
||||
return <GasketMaterialsView {...commonProps} />;
|
||||
case 'BOLT':
|
||||
return <BoltMaterialsView {...commonProps} />;
|
||||
case 'SUPPORT':
|
||||
return <SupportMaterialsView {...commonProps} />;
|
||||
case 'SPECIAL':
|
||||
return <SpecialMaterialsView {...commonProps} />;
|
||||
case 'UNCLASSIFIED':
|
||||
return <UnclassifiedMaterialsView {...commonProps} />;
|
||||
default:
|
||||
return <div>카테고리를 선택해주세요.</div>;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
border: '4px solid #e2e8f0',
|
||||
borderTop: '4px solid #3b82f6',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
margin: '0 auto 20px'
|
||||
}}></div>
|
||||
<div style={{ fontSize: '18px', color: '#64748b', fontWeight: '600' }}>
|
||||
Loading Materials...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '40px',
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
{/* 헤더 섹션 */}
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '20px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
marginBottom: '40px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '28px' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<h2 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: 0,
|
||||
letterSpacing: '-0.025em'
|
||||
}}>
|
||||
BOM Materials Management
|
||||
</h2>
|
||||
{isRevisionMode && (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
color: 'white',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
boxShadow: '0 4px 6px -1px rgba(245, 158, 11, 0.3)'
|
||||
}}>
|
||||
📊 Revision Mode
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#64748b',
|
||||
margin: 0,
|
||||
fontWeight: '400'
|
||||
}}>
|
||||
{bomName} - {currentRevision} | Project: {selectedProject?.job_name || jobNo}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
{isRevisionMode && (
|
||||
<button
|
||||
onClick={() => setShowRevisionPanel(!showRevisionPanel)}
|
||||
style={{
|
||||
background: showRevisionPanel
|
||||
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease',
|
||||
letterSpacing: '0.025em',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
>
|
||||
{showRevisionPanel ? '📊 Hide Revision Panel' : '🔄 Manage Revision'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease',
|
||||
letterSpacing: '0.025em'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = '#f9fafb';
|
||||
e.target.style.borderColor = '#9ca3af';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = 'white';
|
||||
e.target.style.borderColor = '#d1d5db';
|
||||
}}
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 정보 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '20px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#1d4ed8', marginBottom: '4px' }}>
|
||||
{materials.length}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1d4ed8', fontWeight: '500' }}>
|
||||
Total Materials
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#059669', marginBottom: '4px' }}>
|
||||
{getCategoryMaterials(selectedCategory).length}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#059669', fontWeight: '500' }}>
|
||||
{selectedCategory} Items
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#92400e', marginBottom: '4px' }}>
|
||||
{selectedMaterials.size}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#92400e', fontWeight: '500' }}>
|
||||
Selected
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #fee2e2 0%, #fecaca 100%)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#dc2626', marginBottom: '4px' }}>
|
||||
{purchasedMaterials.size}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#dc2626', fontWeight: '500' }}>
|
||||
Purchased
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 탭 */}
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '20px',
|
||||
padding: '24px 32px',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
marginBottom: '40px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||
gap: '16px'
|
||||
}}>
|
||||
{categories
|
||||
.filter((category) => {
|
||||
const count = getCategoryMaterials(category.key).length;
|
||||
return count > 0; // 0개인 카테고리는 숨김
|
||||
})
|
||||
.map((category) => {
|
||||
const isActive = selectedCategory === category.key;
|
||||
const count = getCategoryMaterials(category.key).length;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.key}
|
||||
onClick={() => setSelectedCategory(category.key)}
|
||||
style={{
|
||||
background: isActive
|
||||
? `linear-gradient(135deg, ${category.color} 0%, ${category.color}dd 100%)`
|
||||
: 'white',
|
||||
color: isActive ? 'white' : '#64748b',
|
||||
border: isActive ? 'none' : '1px solid #e2e8f0',
|
||||
borderRadius: '12px',
|
||||
padding: '16px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease',
|
||||
textAlign: 'center',
|
||||
boxShadow: isActive ? `0 4px 14px 0 ${category.color}39` : '0 2px 8px rgba(0,0,0,0.05)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
e.target.style.borderColor = '#cbd5e1';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.target.style.background = 'white';
|
||||
e.target.style.borderColor = '#e2e8f0';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '4px' }}>
|
||||
{category.label}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
opacity: 0.8,
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{count} items
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리별 컨텐츠 */}
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '20px',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{error ? (
|
||||
<div style={{
|
||||
padding: '60px',
|
||||
textAlign: 'center',
|
||||
color: '#dc2626'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>⚠️</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
Error Loading Materials
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
renderCategoryView()
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 리비전 관리 패널 */}
|
||||
{isRevisionMode && showRevisionPanel && (
|
||||
<div style={{ marginTop: '40px' }}>
|
||||
<RevisionManagementPanel
|
||||
jobNo={jobNo}
|
||||
currentFileId={parseInt(fileId)}
|
||||
previousFileId={previousFileId}
|
||||
onRevisionComplete={handleRevisionComplete}
|
||||
onRevisionCancel={handleRevisionCancel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMManagementPage;
|
||||
300
tkeg/web/src/pages/BOMStatusPage.jsx
Normal file
300
tkeg/web/src/pages/BOMStatusPage.jsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api, fetchFiles, deleteFile as deleteFileApi } from '../api';
|
||||
import BOMFileUpload from '../components/BOMFileUpload';
|
||||
|
||||
const BOMStatusPage = ({ jobNo, jobName, onNavigate, selectedProject }) => {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [bomName, setBomName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (jobNo) {
|
||||
fetchFilesList();
|
||||
}
|
||||
}, [jobNo]);
|
||||
|
||||
const fetchFilesList = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/files/', {
|
||||
params: { job_no: jobNo }
|
||||
});
|
||||
|
||||
// API가 배열로 직접 반환하는 경우
|
||||
if (Array.isArray(response.data)) {
|
||||
setFiles(response.data);
|
||||
} else if (response.data && response.data.success) {
|
||||
setFiles(response.data.files || []);
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('파일 목록 로딩 실패:', err);
|
||||
setError('파일 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 업로드
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile || !bomName.trim()) {
|
||||
alert('파일과 BOM 이름을 모두 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('bom_name', bomName.trim());
|
||||
formData.append('job_no', jobNo);
|
||||
|
||||
const response = await api.post('/files/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
alert('파일이 성공적으로 업로드되었습니다!');
|
||||
setSelectedFile(null);
|
||||
setBomName('');
|
||||
await fetchFilesList(); // 목록 새로고침
|
||||
} else {
|
||||
throw new Error(response.data?.message || '업로드 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('파일 업로드 실패:', err);
|
||||
setError('파일 업로드에 실패했습니다.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 삭제
|
||||
const handleDelete = async (fileId) => {
|
||||
if (!window.confirm('정말로 이 파일을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteFileApi(fileId);
|
||||
await fetchFilesList(); // 목록 새로고침
|
||||
} catch (err) {
|
||||
console.error('파일 삭제 실패:', err);
|
||||
setError('파일 삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 자재 관리 페이지로 바로 이동 (단순화)
|
||||
const handleViewMaterials = (file) => {
|
||||
if (onNavigate) {
|
||||
onNavigate('materials', {
|
||||
file_id: file.id,
|
||||
jobNo: file.job_no,
|
||||
bomName: file.bom_name || file.original_filename,
|
||||
revision: file.revision,
|
||||
filename: file.original_filename
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onNavigate) {
|
||||
onNavigate('dashboard');
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
← 메인으로 돌아가기
|
||||
</button>
|
||||
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
📊 BOM 관리 시스템
|
||||
</h1>
|
||||
|
||||
{jobNo && jobName && (
|
||||
<h2 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: '#4299e1',
|
||||
margin: '0 0 24px 0'
|
||||
}}>
|
||||
{jobNo} - {jobName}
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 파일 업로드 컴포넌트 */}
|
||||
<BOMFileUpload
|
||||
bomName={bomName}
|
||||
setBomName={setBomName}
|
||||
selectedFile={selectedFile}
|
||||
setSelectedFile={setSelectedFile}
|
||||
uploading={uploading}
|
||||
handleUpload={handleUpload}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
{/* BOM 목록 */}
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '32px 0 16px 0'
|
||||
}}>
|
||||
업로드된 BOM 목록
|
||||
</h3>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
로딩 중...
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.07)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f7fafc' }}>
|
||||
<th style={{ padding: '16px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>BOM 이름</th>
|
||||
<th style={{ padding: '16px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>파일명</th>
|
||||
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>리비전</th>
|
||||
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>자재 수</th>
|
||||
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>업로드 일시</th>
|
||||
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((file) => (
|
||||
<tr key={file.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ fontWeight: '600', color: '#2d3748' }}>
|
||||
{file.bom_name || file.original_filename}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#718096' }}>
|
||||
{file.description || ''}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px', fontSize: '14px', color: '#4a5568' }}>
|
||||
{file.original_filename}
|
||||
</td>
|
||||
<td style={{ padding: '16px', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
background: '#e6fffa',
|
||||
color: '#065f46',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{file.revision || 'Rev.0'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '16px', textAlign: 'center' }}>
|
||||
{file.parsed_count || 0}개
|
||||
</td>
|
||||
<td style={{ padding: '16px', textAlign: 'center', fontSize: '14px', color: '#718096' }}>
|
||||
{new Date(file.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td style={{ padding: '16px', textAlign: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
|
||||
<button
|
||||
onClick={() => handleViewMaterials(file)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#4299e1',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
📋 자재 보기
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// 리비전 업로드 기능 (추후 구현)
|
||||
alert('리비전 업로드 기능은 준비 중입니다.');
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: 'white',
|
||||
color: '#4299e1',
|
||||
border: '1px solid #4299e1',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
📝 리비전
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(file.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#f56565',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{files.length === 0 && (
|
||||
<div style={{
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
color: '#718096'
|
||||
}}>
|
||||
업로드된 BOM 파일이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMStatusPage;
|
||||
398
tkeg/web/src/pages/InactiveProjectsPage.jsx
Normal file
398
tkeg/web/src/pages/InactiveProjectsPage.jsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const InactiveProjectsPage = ({
|
||||
onNavigate,
|
||||
user,
|
||||
projects,
|
||||
inactiveProjects,
|
||||
onActivateProject,
|
||||
onDeleteProject
|
||||
}) => {
|
||||
const [selectedProjects, setSelectedProjects] = useState(new Set());
|
||||
|
||||
// 비활성 프로젝트 목록 필터링
|
||||
const inactiveProjectList = projects.filter(project =>
|
||||
inactiveProjects.has(project.job_no)
|
||||
);
|
||||
|
||||
// 프로젝트 선택/해제
|
||||
const handleProjectSelect = (projectNo) => {
|
||||
setSelectedProjects(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(projectNo)) {
|
||||
newSet.delete(projectNo);
|
||||
} else {
|
||||
newSet.add(projectNo);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleSelectAll = () => {
|
||||
if (selectedProjects.size === inactiveProjectList.length) {
|
||||
setSelectedProjects(new Set());
|
||||
} else {
|
||||
setSelectedProjects(new Set(inactiveProjectList.map(p => p.job_no)));
|
||||
}
|
||||
};
|
||||
|
||||
// 선택된 프로젝트들 활성화
|
||||
const handleBulkActivate = () => {
|
||||
if (selectedProjects.size === 0) {
|
||||
alert('활성화할 프로젝트를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm(`선택된 ${selectedProjects.size}개 프로젝트를 활성화하시겠습니까?`)) {
|
||||
selectedProjects.forEach(projectNo => {
|
||||
const project = projects.find(p => p.job_no === projectNo);
|
||||
if (project) {
|
||||
onActivateProject(project);
|
||||
}
|
||||
});
|
||||
setSelectedProjects(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
// 선택된 프로젝트들 삭제
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedProjects.size === 0) {
|
||||
alert('삭제할 프로젝트를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm(`선택된 ${selectedProjects.size}개 프로젝트를 완전히 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.`)) {
|
||||
selectedProjects.forEach(projectNo => {
|
||||
onDeleteProject(projectNo);
|
||||
});
|
||||
setSelectedProjects(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '40px',
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '20px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
marginBottom: '40px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '28px' }}>
|
||||
<div>
|
||||
<h2 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 4px 0',
|
||||
letterSpacing: '-0.025em'
|
||||
}}>
|
||||
Inactive Projects Management
|
||||
</h2>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#64748b',
|
||||
margin: 0,
|
||||
fontWeight: '400'
|
||||
}}>
|
||||
Manage deactivated projects - activate or permanently delete
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease',
|
||||
letterSpacing: '0.025em'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = '#f9fafb';
|
||||
e.target.style.borderColor = '#9ca3af';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = 'white';
|
||||
e.target.style.borderColor = '#d1d5db';
|
||||
}}
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 통계 정보 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '20px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: '#92400e', marginBottom: '4px' }}>
|
||||
{inactiveProjectList.length}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#92400e', fontWeight: '500' }}>
|
||||
Inactive Projects
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: '#1d4ed8', marginBottom: '4px' }}>
|
||||
{selectedProjects.size}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1d4ed8', fontWeight: '500' }}>
|
||||
Selected
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 일괄 작업 버튼들 */}
|
||||
{inactiveProjectList.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '24px' }}>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = '#f9fafb';
|
||||
e.target.style.borderColor = '#9ca3af';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = 'white';
|
||||
e.target.style.borderColor = '#d1d5db';
|
||||
}}
|
||||
>
|
||||
{selectedProjects.size === inactiveProjectList.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleBulkActivate}
|
||||
disabled={selectedProjects.size === 0}
|
||||
style={{
|
||||
background: selectedProjects.size > 0 ? 'linear-gradient(135deg, #10b981 0%, #059669 100%)' : '#e5e7eb',
|
||||
color: selectedProjects.size > 0 ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: selectedProjects.size > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
Activate Selected ({selectedProjects.size})
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleBulkDelete}
|
||||
disabled={selectedProjects.size === 0}
|
||||
style={{
|
||||
background: selectedProjects.size > 0 ? 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' : '#e5e7eb',
|
||||
color: selectedProjects.size > 0 ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: selectedProjects.size > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
Delete Selected ({selectedProjects.size})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비활성 프로젝트 목록 */}
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '20px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 24px 0',
|
||||
letterSpacing: '-0.025em'
|
||||
}}>
|
||||
Inactive Projects List
|
||||
</h3>
|
||||
|
||||
{inactiveProjectList.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#64748b'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📂</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
No Inactive Projects
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
All projects are currently active
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gap: '16px'
|
||||
}}>
|
||||
{inactiveProjectList.map((project) => (
|
||||
<div
|
||||
key={project.job_no}
|
||||
style={{
|
||||
background: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.05)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.borderColor = '#cbd5e1';
|
||||
e.target.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.borderColor = '#e2e8f0';
|
||||
e.target.style.boxShadow = '0 2px 8px rgba(0,0,0,0.05)';
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', flex: 1 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedProjects.has(project.job_no)}
|
||||
onChange={() => handleProjectSelect(project.job_no)}
|
||||
style={{
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#1a202c',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{project.job_name || project.job_no}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#64748b'
|
||||
}}>
|
||||
Code: {project.job_no} | Client: {project.client_name || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(`"${project.job_name || project.job_no}" 프로젝트를 활성화하시겠습니까?`)) {
|
||||
onActivateProject(project);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.transform = 'translateY(-1px)';
|
||||
e.target.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.transform = 'translateY(0)';
|
||||
e.target.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
Activate
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(`"${project.job_name || project.job_no}" 프로젝트를 완전히 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.`)) {
|
||||
onDeleteProject(project.job_no);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.transform = 'translateY(-1px)';
|
||||
e.target.style.boxShadow = '0 4px 12px rgba(239, 68, 68, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.transform = 'translateY(0)';
|
||||
e.target.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InactiveProjectsPage;
|
||||
334
tkeg/web/src/pages/JobRegistrationPage.css
Normal file
334
tkeg/web/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
tkeg/web/src/pages/JobRegistrationPage.jsx
Normal file
359
tkeg/web/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;
|
||||
279
tkeg/web/src/pages/JobSelectionPage.jsx
Normal file
279
tkeg/web/src/pages/JobSelectionPage.jsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { fetchJobs } from '../api';
|
||||
import api from '../api';
|
||||
|
||||
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 [editingJobNo, setEditingJobNo] = useState(null);
|
||||
const [editedName, setEditedName] = useState('');
|
||||
|
||||
// 프로젝트 이름 수정
|
||||
const updateJobName = async (jobNo) => {
|
||||
try {
|
||||
const project = jobs.find(j => j.job_no === jobNo);
|
||||
if (!project) return;
|
||||
|
||||
const response = await api.patch(`/dashboard/projects/${project.id}?job_name=${encodeURIComponent(editedName)}`);
|
||||
|
||||
if (response.data.success) {
|
||||
// 로컬 상태 업데이트
|
||||
setJobs(jobs.map(j =>
|
||||
j.job_no === jobNo ? { ...j, job_name: editedName } : j
|
||||
));
|
||||
|
||||
// 선택된 프로젝트 이름도 업데이트
|
||||
if (selectedJobNo === jobNo) {
|
||||
setSelectedJobName(editedName);
|
||||
}
|
||||
|
||||
setEditingJobNo(null);
|
||||
setEditedName('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 이름 수정 실패:', error);
|
||||
alert('프로젝트 이름 수정에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
}, []);
|
||||
|
||||
const handleSelect = (e) => {
|
||||
const jobNo = e.target.value;
|
||||
setSelectedJobNo(jobNo);
|
||||
const job = jobs.find(j => j.job_no === jobNo);
|
||||
setSelectedJobName(job ? job.job_name : '');
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedJobNo && selectedJobName && onJobSelect) {
|
||||
onJobSelect(selectedJobNo, selectedJobName);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div>
|
||||
선택된 프로젝트: <strong>{selectedJobNo} - {selectedJobName}</strong>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingJobNo(selectedJobNo);
|
||||
setEditedName(selectedJobName);
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#48bb78',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
title="프로젝트 이름 수정"
|
||||
>
|
||||
✏️ 수정
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingJobNo && (
|
||||
<div style={{
|
||||
background: '#fff5f5',
|
||||
border: '2px solid #fc8181',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<div style={{ marginBottom: '12px', fontWeight: '600', color: '#742a2a' }}>
|
||||
프로젝트 이름 수정
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={editedName}
|
||||
onChange={(e) => setEditedName(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') updateJobName(editingJobNo);
|
||||
}}
|
||||
placeholder="새 프로젝트 이름"
|
||||
autoFocus
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
border: '2px solid #3b82f6',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateJobName(editingJobNo)}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
background: '#10b981',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingJobNo(null);
|
||||
setEditedName('');
|
||||
}}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
background: '#ef4444',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</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;
|
||||
528
tkeg/web/src/pages/LogMonitoringPage.jsx
Normal file
528
tkeg/web/src/pages/LogMonitoringPage.jsx
Normal file
@@ -0,0 +1,528 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
import { reportError, logUserActionError } from '../utils/errorLogger';
|
||||
|
||||
const LogMonitoringPage = ({ onNavigate, user }) => {
|
||||
const [activeTab, setActiveTab] = useState('login-logs'); // 'login-logs', 'activity-logs', 'system-logs'
|
||||
const [stats, setStats] = useState({
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
todayLogins: 0,
|
||||
failedLogins: 0,
|
||||
recentErrors: 0
|
||||
});
|
||||
const [recentActivity, setRecentActivity] = useState([]);
|
||||
const [activityLogs, setActivityLogs] = useState([]);
|
||||
const [frontendErrors, setFrontendErrors] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
// 30초마다 자동 새로고침
|
||||
const interval = setInterval(loadDashboardData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadActivityLogs = async () => {
|
||||
try {
|
||||
const response = await api.get('/auth/logs/system?limit=50');
|
||||
if (response.data.success) {
|
||||
setActivityLogs(response.data.logs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('활동 로그 로딩 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// 활동 로그도 함께 로드
|
||||
if (activeTab === 'activity-logs') {
|
||||
await loadActivityLogs();
|
||||
}
|
||||
|
||||
// 병렬로 데이터 로드
|
||||
const [usersResponse, loginLogsResponse] = await Promise.all([
|
||||
api.get('/auth/users'),
|
||||
api.get('/auth/logs/login', { params: { limit: 20 } })
|
||||
]);
|
||||
|
||||
// 사용자 통계
|
||||
if (usersResponse.data.success) {
|
||||
const users = usersResponse.data.users;
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
totalUsers: users.length,
|
||||
activeUsers: users.filter(u => u.is_active).length
|
||||
}));
|
||||
}
|
||||
|
||||
// 로그인 로그 통계
|
||||
if (loginLogsResponse.data.success) {
|
||||
const logs = loginLogsResponse.data.logs;
|
||||
const today = new Date().toDateString();
|
||||
|
||||
const todayLogins = logs.filter(log =>
|
||||
new Date(log.login_time).toDateString() === today &&
|
||||
log.login_status === 'success'
|
||||
).length;
|
||||
|
||||
const failedLogins = logs.filter(log =>
|
||||
new Date(log.login_time).toDateString() === today &&
|
||||
log.login_status === 'failed'
|
||||
).length;
|
||||
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
todayLogins,
|
||||
failedLogins
|
||||
}));
|
||||
|
||||
setRecentActivity(logs.slice(0, 10));
|
||||
}
|
||||
|
||||
// 프론트엔드 오류 로그 (로컬 스토리지에서)
|
||||
const localErrors = JSON.parse(localStorage.getItem('frontend_errors') || '[]');
|
||||
const recentErrors = localErrors.filter(error => {
|
||||
const errorDate = new Date(error.timestamp);
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
return errorDate > oneDayAgo;
|
||||
});
|
||||
|
||||
setFrontendErrors(recentErrors.slice(0, 10));
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
recentErrors: recentErrors.length
|
||||
}));
|
||||
|
||||
} catch (err) {
|
||||
console.error('Load dashboard data error:', err);
|
||||
setMessage({ type: 'error', text: '대시보드 데이터 로드 중 오류가 발생했습니다' });
|
||||
logUserActionError('load_dashboard_data', err, { userId: user?.user_id });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearFrontendErrors = () => {
|
||||
localStorage.removeItem('frontend_errors');
|
||||
setFrontendErrors([]);
|
||||
setStats(prev => ({ ...prev, recentErrors: 0 }));
|
||||
setMessage({ type: 'success', text: '프론트엔드 오류 로그가 삭제되었습니다' });
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
try {
|
||||
return new Date(dateString).toLocaleString('ko-KR', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const getActivityIcon = (status) => {
|
||||
return status === 'success' ? '✅' : '❌';
|
||||
};
|
||||
|
||||
const getErrorTypeIcon = (type) => {
|
||||
const icons = {
|
||||
'javascript_error': '❌',
|
||||
'api_error': '🌐',
|
||||
'user_action_error': '👤',
|
||||
'promise_rejection': '⚠️',
|
||||
'react_error_boundary': '⚛️'
|
||||
};
|
||||
return icons[type] || '❓';
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#f8f9fa' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
padding: '16px 32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<button
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#28a745',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
|
||||
onMouseLeave={(e) => e.target.style.background = 'none'}
|
||||
title="대시보드로 돌아가기"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: '700', color: '#2d3748', margin: 0 }}>
|
||||
📈 로그 모니터링
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
<div style={{ background: 'white', borderBottom: '1px solid #e9ecef', padding: '0 32px' }}>
|
||||
<div style={{ display: 'flex', gap: '0' }}>
|
||||
<button
|
||||
onClick={() => setActiveTab('login-logs')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
border: 'none',
|
||||
background: activeTab === 'login-logs' ? '#4299e1' : 'transparent',
|
||||
color: activeTab === 'login-logs' ? 'white' : '#4a5568',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
cursor: 'pointer',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
borderBottom: activeTab === 'login-logs' ? '2px solid #4299e1' : '2px solid transparent'
|
||||
}}
|
||||
>
|
||||
🔐 로그인 로그
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('activity-logs')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
border: 'none',
|
||||
background: activeTab === 'activity-logs' ? '#4299e1' : 'transparent',
|
||||
color: activeTab === 'activity-logs' ? 'white' : '#4a5568',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
cursor: 'pointer',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
borderBottom: activeTab === 'activity-logs' ? '2px solid #4299e1' : '2px solid transparent'
|
||||
}}
|
||||
>
|
||||
📊 활동 로그
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('system-logs')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
border: 'none',
|
||||
background: activeTab === 'system-logs' ? '#4299e1' : 'transparent',
|
||||
color: activeTab === 'system-logs' ? 'white' : '#4a5568',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
cursor: 'pointer',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
borderBottom: activeTab === 'system-logs' ? '2px solid #4299e1' : '2px solid transparent'
|
||||
}}
|
||||
>
|
||||
🖥️ 시스템 로그
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={loadDashboardData}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
background: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
opacity: isLoading ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
🔄 새로고침
|
||||
</button>
|
||||
|
||||
{frontendErrors.length > 0 && (
|
||||
<button
|
||||
onClick={clearFrontendErrors}
|
||||
style={{
|
||||
background: '#dc3545',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
🗑️ 오류 로그 삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '32px', maxWidth: '1400px', margin: '0 auto' }}>
|
||||
{/* 메시지 표시 */}
|
||||
{message.text && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '24px',
|
||||
backgroundColor: message.type === 'success' ? '#d1edff' : '#f8d7da',
|
||||
border: `1px solid ${message.type === 'success' ? '#bee5eb' : '#f5c6cb'}`,
|
||||
color: message.type === 'success' ? '#0c5460' : '#721c24'
|
||||
}}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
|
||||
gap: '20px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e9ecef'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<div style={{ fontSize: '24px' }}>👥</div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
|
||||
전체 사용자
|
||||
</h3>
|
||||
</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#2d3748' }}>
|
||||
{stats.totalUsers}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#28a745' }}>
|
||||
활성: {stats.activeUsers}명
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e9ecef'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<div style={{ fontSize: '24px' }}>✅</div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
|
||||
오늘 로그인
|
||||
</h3>
|
||||
</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#28a745' }}>
|
||||
{stats.todayLogins}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6c757d' }}>
|
||||
성공한 로그인
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e9ecef'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<div style={{ fontSize: '24px' }}>❌</div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
|
||||
로그인 실패
|
||||
</h3>
|
||||
</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#dc3545' }}>
|
||||
{stats.failedLogins}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6c757d' }}>
|
||||
오늘 실패 횟수
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e9ecef'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<div style={{ fontSize: '24px' }}>❌</div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
|
||||
최근 오류
|
||||
</h3>
|
||||
</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#ffc107' }}>
|
||||
{stats.recentErrors}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6c757d' }}>
|
||||
24시간 내
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 콘텐츠 그리드 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '24px'
|
||||
}}>
|
||||
{/* 최근 활동 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
background: '#f8f9fa'
|
||||
}}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
|
||||
🔐 최근 로그인 활동
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
|
||||
{isLoading ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '16px', color: '#6c757d' }}>로딩 중...</div>
|
||||
</div>
|
||||
) : recentActivity.length === 0 ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '16px', color: '#6c757d' }}>최근 활동이 없습니다</div>
|
||||
</div>
|
||||
) : (
|
||||
recentActivity.map((activity, index) => (
|
||||
<div key={index} style={{
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid #f1f3f4',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<div style={{ fontSize: '20px' }}>
|
||||
{getActivityIcon(activity.login_status)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||
{activity.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||
@{activity.username} • {activity.ip_address}
|
||||
</div>
|
||||
{activity.failure_reason && (
|
||||
<div style={{ fontSize: '12px', color: '#dc3545', marginTop: '2px' }}>
|
||||
{activity.failure_reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||
{formatDateTime(activity.login_time)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 프론트엔드 오류 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
background: '#f8f9fa'
|
||||
}}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
|
||||
❌ 프론트엔드 오류
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
|
||||
{frontendErrors.length === 0 ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '16px', color: '#6c757d' }}>최근 오류가 없습니다</div>
|
||||
</div>
|
||||
) : (
|
||||
frontendErrors.map((error, index) => (
|
||||
<div key={index} style={{
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid #f1f3f4',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<div style={{ fontSize: '16px', marginTop: '2px' }}>
|
||||
{getErrorTypeIcon(error.type)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#dc3545' }}>
|
||||
{error.type?.replace('_', ' ').toUpperCase() || 'ERROR'}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
color: '#495057',
|
||||
marginTop: '4px',
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{error.message?.substring(0, 100)}
|
||||
{error.message?.length > 100 && '...'}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d', marginTop: '4px' }}>
|
||||
{error.url && (
|
||||
<span>{new URL(error.url).pathname} • </span>
|
||||
)}
|
||||
{formatDateTime(error.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자동 새로고침 안내 */}
|
||||
<div style={{
|
||||
marginTop: '24px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
border: '1px solid #bbdefb',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<p style={{ fontSize: '14px', color: '#1565c0', margin: 0 }}>
|
||||
📊 이 페이지는 30초마다 자동으로 새로고침됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogMonitoringPage;
|
||||
215
tkeg/web/src/pages/MainPage.css
Normal file
215
tkeg/web/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
tkeg/web/src/pages/MainPage.jsx
Normal file
85
tkeg/web/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;
|
||||
1193
tkeg/web/src/pages/NewMaterialsPage.css
Normal file
1193
tkeg/web/src/pages/NewMaterialsPage.css
Normal file
File diff suppressed because it is too large
Load Diff
2849
tkeg/web/src/pages/NewMaterialsPage.jsx
Normal file
2849
tkeg/web/src/pages/NewMaterialsPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
358
tkeg/web/src/pages/ProjectWorkspacePage.jsx
Normal file
358
tkeg/web/src/pages/ProjectWorkspacePage.jsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
const ProjectWorkspacePage = ({ project, user, onNavigate, onBackToDashboard }) => {
|
||||
const [projectStats, setProjectStats] = useState(null);
|
||||
const [recentFiles, setRecentFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
loadProjectData();
|
||||
}
|
||||
}, [project]);
|
||||
|
||||
const loadProjectData = async () => {
|
||||
try {
|
||||
// 실제 파일 데이터만 로드
|
||||
const filesResponse = await api.get(`/files?job_no=${project.job_no}&limit=5`);
|
||||
|
||||
if (filesResponse.data && Array.isArray(filesResponse.data)) {
|
||||
setRecentFiles(filesResponse.data);
|
||||
|
||||
// 파일 데이터를 기반으로 통계 계산
|
||||
const stats = {
|
||||
totalFiles: filesResponse.data.length,
|
||||
totalMaterials: filesResponse.data.reduce((sum, file) => sum + (file.parsed_count || 0), 0),
|
||||
classifiedMaterials: 0, // API에서 분류 정보를 가져와야 함
|
||||
pendingVerification: 0, // API에서 검증 정보를 가져와야 함
|
||||
};
|
||||
setProjectStats(stats);
|
||||
} else {
|
||||
setRecentFiles([]);
|
||||
setProjectStats({
|
||||
totalFiles: 0,
|
||||
totalMaterials: 0,
|
||||
classifiedMaterials: 0,
|
||||
pendingVerification: 0
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 데이터 로딩 실패:', error);
|
||||
setRecentFiles([]);
|
||||
setProjectStats({
|
||||
totalFiles: 0,
|
||||
totalMaterials: 0,
|
||||
classifiedMaterials: 0,
|
||||
pendingVerification: 0
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getAvailableActions = () => {
|
||||
const userRole = user?.role || 'user';
|
||||
|
||||
const allActions = {
|
||||
// BOM 관리 (통합)
|
||||
'bom-management': {
|
||||
title: 'BOM 관리',
|
||||
description: 'BOM 파일 업로드, 관리 및 리비전 추적을 수행합니다',
|
||||
icon: '📋',
|
||||
color: '#667eea',
|
||||
roles: ['designer', 'manager', 'admin'],
|
||||
path: 'bom-status'
|
||||
},
|
||||
// 자재 관리
|
||||
'material-management': {
|
||||
title: '자재 관리',
|
||||
description: '자재 분류, 검증 및 구매 관리를 수행합니다',
|
||||
icon: '🔧',
|
||||
color: '#48bb78',
|
||||
roles: ['designer', 'purchaser', 'manager', 'admin'],
|
||||
path: 'materials'
|
||||
}
|
||||
};
|
||||
|
||||
// 사용자 권한에 따라 필터링
|
||||
return Object.entries(allActions).filter(([key, action]) =>
|
||||
action.roles.includes(userRole)
|
||||
);
|
||||
};
|
||||
|
||||
const handleActionClick = (actionPath) => {
|
||||
switch (actionPath) {
|
||||
case 'bom-management':
|
||||
onNavigate('bom-status', {
|
||||
job_no: project.job_no,
|
||||
job_name: project.project_name
|
||||
});
|
||||
break;
|
||||
case 'material-management':
|
||||
onNavigate('materials', {
|
||||
job_no: project.job_no,
|
||||
job_name: project.project_name
|
||||
});
|
||||
break;
|
||||
default:
|
||||
alert(`${actionPath} 기능은 곧 구현될 예정입니다.`);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '200px'
|
||||
}}>
|
||||
<div>프로젝트 데이터를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const availableActions = getAvailableActions();
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<button
|
||||
onClick={onBackToDashboard}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
marginRight: '16px',
|
||||
padding: '8px'
|
||||
}}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<div>
|
||||
<h1 style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '28px',
|
||||
fontWeight: 'bold',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
{project.project_name}
|
||||
</h1>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
{project.job_no} • 진행률: {project.progress || 0}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 통계 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '20px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
{[
|
||||
{ label: 'BOM 파일', value: projectStats.totalFiles, icon: '📄', color: '#667eea' },
|
||||
{ label: '전체 자재', value: projectStats.totalMaterials, icon: '📦', color: '#48bb78' },
|
||||
{ label: '분류 완료', value: projectStats.classifiedMaterials, icon: '✅', color: '#38b2ac' },
|
||||
{ label: '검증 대기', value: projectStats.pendingVerification, icon: '⏳', color: '#ed8936' }
|
||||
].map((stat, index) => (
|
||||
<div key={index} style={{
|
||||
background: 'white',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{stat.label}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: stat.color
|
||||
}}>
|
||||
{stat.value}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '24px' }}>
|
||||
{stat.icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 업무 메뉴 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<h2 style={{
|
||||
margin: '0 0 24px 0',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
🚀 사용 가능한 업무
|
||||
</h2>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
gap: '20px'
|
||||
}}>
|
||||
{availableActions.map(([key, action]) => (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => handleActionClick(key)}
|
||||
style={{
|
||||
padding: '20px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'white'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.borderColor = action.color;
|
||||
e.target.style.boxShadow = `0 4px 12px ${action.color}20`;
|
||||
e.target.style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.borderColor = '#e2e8f0';
|
||||
e.target.style.boxShadow = 'none';
|
||||
e.target.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '32px',
|
||||
lineHeight: 1
|
||||
}}>
|
||||
{action.icon}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h3 style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: action.color
|
||||
}}>
|
||||
{action.title}
|
||||
</h3>
|
||||
<p style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
color: '#718096',
|
||||
lineHeight: 1.5
|
||||
}}>
|
||||
{action.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 최근 활동 (옵션) */}
|
||||
{recentFiles.length > 0 && (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
|
||||
}}>
|
||||
<h2 style={{
|
||||
margin: '0 0 24px 0',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
📁 최근 BOM 파일
|
||||
</h2>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{recentFiles.map((file, index) => (
|
||||
<div key={index} style={{
|
||||
padding: '16px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: '500',
|
||||
color: '#2d3748',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{file.original_filename || file.filename}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
{file.revision} • {file.uploaded_by || '시스템'} • {file.parsed_count || 0}개 자재
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleActionClick('materials')}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#667eea',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
자재 보기
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectWorkspacePage;
|
||||
497
tkeg/web/src/pages/ProjectsPage.jsx
Normal file
497
tkeg/web/src/pages/ProjectsPage.jsx
Normal file
@@ -0,0 +1,497 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const ProjectsPage = ({ user }) => {
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState(null);
|
||||
const [editedName, setEditedName] = useState('');
|
||||
|
||||
// 프로젝트 이름 편집 시작
|
||||
const startEditing = (project) => {
|
||||
setEditingProject(project.id);
|
||||
setEditedName(project.name);
|
||||
};
|
||||
|
||||
// 프로젝트 이름 저장
|
||||
const saveProjectName = async (projectId) => {
|
||||
try {
|
||||
// TODO: API 호출하여 프로젝트 이름 업데이트
|
||||
// await api.patch(`/dashboard/projects/${projectId}?job_name=${encodeURIComponent(editedName)}`);
|
||||
|
||||
// 임시: 로컬 상태만 업데이트
|
||||
setProjects(projects.map(p =>
|
||||
p.id === projectId ? { ...p, name: editedName } : p
|
||||
));
|
||||
|
||||
setEditingProject(null);
|
||||
setEditedName('');
|
||||
} catch (error) {
|
||||
console.error('프로젝트 이름 수정 실패:', error);
|
||||
alert('프로젝트 이름 수정에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 편집 취소
|
||||
const cancelEditing = () => {
|
||||
setEditingProject(null);
|
||||
setEditedName('');
|
||||
};
|
||||
|
||||
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', cursor: 'pointer' }}
|
||||
onDoubleClick={() => startEditing(project)}
|
||||
title="더블클릭하여 이름 수정"
|
||||
>
|
||||
{editingProject === project.id ? (
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={editedName}
|
||||
onChange={(e) => setEditedName(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') saveProjectName(project.id);
|
||||
if (e.key === 'Escape') cancelEditing();
|
||||
}}
|
||||
autoFocus
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 10px',
|
||||
border: '2px solid #3b82f6',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveProjectName(project.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#10b981',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditing}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#ef4444',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span>{project.name}</span>
|
||||
)}
|
||||
</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;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
388
tkeg/web/src/pages/PurchaseBatchPage.jsx
Normal file
388
tkeg/web/src/pages/PurchaseBatchPage.jsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
const PurchaseBatchPage = ({ onNavigate, fileId, jobNo }) => {
|
||||
const [batches, setBatches] = useState([]);
|
||||
const [selectedBatch, setSelectedBatch] = useState(null);
|
||||
const [batchMaterials, setBatchMaterials] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('all'); // all, pending, requested, ordered, received
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
|
||||
useEffect(() => {
|
||||
loadBatches();
|
||||
}, [fileId, jobNo]);
|
||||
|
||||
const loadBatches = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const params = {};
|
||||
if (fileId) params.file_id = fileId;
|
||||
if (jobNo) params.job_no = jobNo;
|
||||
|
||||
const response = await api.get('/export/batches', { params });
|
||||
setBatches(response.data.batches || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load batches:', error);
|
||||
setMessage({ type: 'error', text: '배치 목록 로드 실패' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadBatchMaterials = async (exportId) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await api.get(`/export/batch/${exportId}/materials`);
|
||||
setBatchMaterials(response.data.materials || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load batch materials:', error);
|
||||
setMessage({ type: 'error', text: '자재 목록 로드 실패' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchSelect = (batch) => {
|
||||
setSelectedBatch(batch);
|
||||
loadBatchMaterials(batch.export_id);
|
||||
};
|
||||
|
||||
const handleDownloadExcel = async (exportId, batchNo) => {
|
||||
try {
|
||||
const response = await api.get(`/export/batch/${exportId}/download`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `batch_${batchNo}.xlsx`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
setMessage({ type: 'success', text: '엑셀 다운로드 완료' });
|
||||
} catch (error) {
|
||||
console.error('Failed to download excel:', error);
|
||||
setMessage({ type: 'error', text: '엑셀 다운로드 실패' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchStatusUpdate = async (exportId, newStatus) => {
|
||||
try {
|
||||
const prNo = prompt('구매요청 번호 (PR)를 입력하세요:');
|
||||
const response = await api.patch(`/export/batch/${exportId}/status`, {
|
||||
status: newStatus,
|
||||
purchase_request_no: prNo
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setMessage({ type: 'success', text: response.data.message });
|
||||
loadBatches();
|
||||
if (selectedBatch?.export_id === exportId) {
|
||||
loadBatchMaterials(exportId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update batch status:', error);
|
||||
setMessage({ type: 'error', text: '상태 업데이트 실패' });
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const styles = {
|
||||
pending: { bg: '#FFFFE0', color: '#856404', text: '구매 전' },
|
||||
requested: { bg: '#FFE4B5', color: '#8B4513', text: '구매신청' },
|
||||
in_progress: { bg: '#ADD8E6', color: '#00008B', text: '진행중' },
|
||||
ordered: { bg: '#87CEEB', color: '#4682B4', text: '발주완료' },
|
||||
received: { bg: '#90EE90', color: '#228B22', text: '입고완료' },
|
||||
completed: { bg: '#98FB98', color: '#006400', text: '완료' }
|
||||
};
|
||||
|
||||
const style = styles[status] || styles.pending;
|
||||
|
||||
return (
|
||||
<span style={{
|
||||
background: style.bg,
|
||||
color: style.color,
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{style.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const filteredBatches = activeTab === 'all'
|
||||
? batches
|
||||
: batches.filter(b => b.batch_status === activeTab);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
구매 배치 관리
|
||||
</h1>
|
||||
<p style={{ color: '#6c757d' }}>
|
||||
엑셀로 내보낸 자재들을 배치 단위로 관리합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 메시지 */}
|
||||
{message.text && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
marginBottom: '16px',
|
||||
borderRadius: '8px',
|
||||
background: message.type === 'error' ? '#fee' : '#e6ffe6',
|
||||
color: message.type === 'error' ? '#dc3545' : '#28a745',
|
||||
border: `1px solid ${message.type === 'error' ? '#fcc' : '#cfc'}`
|
||||
}}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '20px' }}>
|
||||
{[
|
||||
{ key: 'all', label: '전체' },
|
||||
{ key: 'pending', label: '구매 전' },
|
||||
{ key: 'requested', label: '구매신청' },
|
||||
{ key: 'in_progress', label: '진행중' },
|
||||
{ key: 'completed', label: '완료' }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border: activeTab === tab.key ? '2px solid #007bff' : '1px solid #dee2e6',
|
||||
background: activeTab === tab.key ? '#007bff' : 'white',
|
||||
color: activeTab === tab.key ? 'white' : '#495057',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '400px 1fr', gap: '24px' }}>
|
||||
{/* 배치 목록 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
background: '#f8f9fa'
|
||||
}}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: '600', margin: 0 }}>
|
||||
배치 목록 ({filteredBatches.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ maxHeight: '600px', overflow: 'auto' }}>
|
||||
{isLoading ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||
로딩중...
|
||||
</div>
|
||||
) : filteredBatches.length === 0 ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#6c757d' }}>
|
||||
배치가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
filteredBatches.map(batch => (
|
||||
<div
|
||||
key={batch.export_id}
|
||||
onClick={() => handleBatchSelect(batch)}
|
||||
style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #f1f3f4',
|
||||
cursor: 'pointer',
|
||||
background: selectedBatch?.export_id === batch.export_id ? '#f0f8ff' : 'white',
|
||||
transition: 'background 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedBatch?.export_id !== batch.export_id) {
|
||||
e.currentTarget.style.background = '#f8f9fa';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedBatch?.export_id !== batch.export_id) {
|
||||
e.currentTarget.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontWeight: '600', fontSize: '14px' }}>
|
||||
{batch.batch_no}
|
||||
</span>
|
||||
{getStatusBadge(batch.batch_status)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '12px', color: '#6c757d', marginBottom: '4px' }}>
|
||||
{batch.job_no} - {batch.job_name}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '12px', color: '#6c757d', marginBottom: '8px' }}>
|
||||
{batch.category || '전체'} | {batch.material_count}개 자재
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', fontSize: '11px' }}>
|
||||
<span style={{ background: '#e9ecef', padding: '2px 6px', borderRadius: '4px' }}>
|
||||
대기: {batch.status_detail.pending}
|
||||
</span>
|
||||
<span style={{ background: '#fff3cd', padding: '2px 6px', borderRadius: '4px' }}>
|
||||
신청: {batch.status_detail.requested}
|
||||
</span>
|
||||
<span style={{ background: '#cce5ff', padding: '2px 6px', borderRadius: '4px' }}>
|
||||
발주: {batch.status_detail.ordered}
|
||||
</span>
|
||||
<span style={{ background: '#d4edda', padding: '2px 6px', borderRadius: '4px' }}>
|
||||
입고: {batch.status_detail.received}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '8px', fontSize: '11px', color: '#6c757d' }}>
|
||||
{new Date(batch.export_date).toLocaleDateString()} | {batch.exported_by}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 배치 상세 */}
|
||||
{selectedBatch ? (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
background: '#f8f9fa'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: '600', margin: 0 }}>
|
||||
배치 {selectedBatch.batch_no}
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => handleDownloadExcel(selectedBatch.export_id, selectedBatch.batch_no)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
📥 엑셀 다운로드
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBatchStatusUpdate(selectedBatch.export_id, 'requested')}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#ffc107',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
구매신청
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px' }}>
|
||||
<div style={{ overflow: 'auto', maxHeight: '500px' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8f9fa' }}>
|
||||
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>No</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>카테고리</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>자재 설명</th>
|
||||
<th style={{ padding: '8px', textAlign: 'center', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>수량</th>
|
||||
<th style={{ padding: '8px', textAlign: 'center', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>상태</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>PR번호</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>PO번호</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batchMaterials.map((material, idx) => (
|
||||
<tr key={material.exported_material_id} style={{ borderBottom: '1px solid #f1f3f4' }}>
|
||||
<td style={{ padding: '8px', fontSize: '12px' }}>{idx + 1}</td>
|
||||
<td style={{ padding: '8px', fontSize: '12px' }}>
|
||||
<span style={{
|
||||
background: '#e9ecef',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
{material.category}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '8px', fontSize: '12px' }}>{material.description}</td>
|
||||
<td style={{ padding: '8px', fontSize: '12px', textAlign: 'center' }}>
|
||||
{material.quantity} {material.unit}
|
||||
</td>
|
||||
<td style={{ padding: '8px', textAlign: 'center' }}>
|
||||
{getStatusBadge(material.purchase_status)}
|
||||
</td>
|
||||
<td style={{ padding: '8px', fontSize: '12px' }}>
|
||||
{material.purchase_request_no || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '8px', fontSize: '12px' }}>
|
||||
{material.purchase_order_no || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '400px'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', color: '#6c757d' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📦</div>
|
||||
<div style={{ fontSize: '16px' }}>배치를 선택하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseBatchPage;
|
||||
211
tkeg/web/src/pages/PurchaseRequestPage.css
Normal file
211
tkeg/web/src/pages/PurchaseRequestPage.css
Normal file
@@ -0,0 +1,211 @@
|
||||
.purchase-request-page {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #007bff;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #6c757d;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: grid;
|
||||
grid-template-columns: 400px 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 구매신청 목록 패널 */
|
||||
.requests-panel {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
background: #f8f9fa;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.requests-list {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.request-card {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f1f3f4;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.request-card:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.request-card.selected {
|
||||
background: #e7f3ff;
|
||||
}
|
||||
|
||||
.request-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.request-no {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.request-date {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.request-info {
|
||||
font-size: 13px;
|
||||
color: #495057;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.material-count {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.request-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.requested-by {
|
||||
font-size: 11px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
/* 상세 패널 */
|
||||
.details-panel {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.excel-btn {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.excel-btn:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.materials-table {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.materials-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.materials-table th {
|
||||
background: #f8f9fa;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.materials-table td {
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #f1f3f4;
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
background: #e9ecef;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
}
|
||||
419
tkeg/web/src/pages/PurchaseRequestPage.jsx
Normal file
419
tkeg/web/src/pages/PurchaseRequestPage.jsx
Normal file
@@ -0,0 +1,419 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
import { exportMaterialsToExcel } from '../utils/excelExport';
|
||||
import './PurchaseRequestPage.css';
|
||||
|
||||
const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) => {
|
||||
const [requests, setRequests] = useState([]);
|
||||
const [selectedRequest, setSelectedRequest] = useState(null);
|
||||
const [requestMaterials, setRequestMaterials] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(null);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadRequests();
|
||||
}, [fileId, jobNo, selectedProject]);
|
||||
|
||||
const loadRequests = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const params = {};
|
||||
|
||||
// 선택된 프로젝트가 있으면 해당 프로젝트만 조회
|
||||
if (selectedProject) {
|
||||
params.job_no = selectedProject.job_no || selectedProject.official_project_code;
|
||||
} else if (jobNo) {
|
||||
params.job_no = jobNo;
|
||||
}
|
||||
|
||||
if (fileId) params.file_id = fileId;
|
||||
|
||||
console.log('🔍 구매신청 목록 조회:', params);
|
||||
const response = await api.get('/purchase-request/list', { params });
|
||||
setRequests(response.data.requests || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load requests:', error);
|
||||
// API 오류 시 대시보드로 리다이렉트
|
||||
if (error.response?.status === 500 || error.response?.status === 404) {
|
||||
alert('구매신청 페이지에 문제가 발생했습니다. 대시보드로 이동합니다.');
|
||||
onNavigate('dashboard');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRequestMaterials = async (requestId) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await api.get(`/purchase-request/${requestId}/materials`);
|
||||
// 그룹화된 자재가 있으면 우선 표시, 없으면 개별 자재 표시
|
||||
if (response.data.grouped_materials && response.data.grouped_materials.length > 0) {
|
||||
setRequestMaterials(response.data.grouped_materials);
|
||||
} else {
|
||||
setRequestMaterials(response.data.materials || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load materials:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestSelect = (request) => {
|
||||
setSelectedRequest(request);
|
||||
loadRequestMaterials(request.request_id);
|
||||
};
|
||||
|
||||
const handleDownloadExcel = async (requestId, requestNo) => {
|
||||
try {
|
||||
console.log('📥 엑셀 다운로드 시작:', requestId, requestNo);
|
||||
|
||||
// 서버에서 생성된 엑셀 파일 직접 다운로드 (BOM 페이지와 동일한 파일)
|
||||
const response = await api.get(`/purchase-request/${requestId}/download-excel`, {
|
||||
responseType: 'blob' // 파일 다운로드용
|
||||
});
|
||||
|
||||
// 파일 다운로드 처리
|
||||
const blob = new Blob([response.data], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${requestNo}_재다운로드.xlsx`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
console.log('✅ 엑셀 파일 다운로드 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 엑셀 다운로드 실패:', error);
|
||||
alert('엑셀 다운로드 실패: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTitle = (request) => {
|
||||
setEditingTitle(request.request_id);
|
||||
setNewTitle(request.request_no);
|
||||
};
|
||||
|
||||
const handleSaveTitle = async (requestId) => {
|
||||
try {
|
||||
const response = await api.patch(`/purchase-request/${requestId}/title`, {
|
||||
title: newTitle
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
// 목록에서 해당 요청의 제목 업데이트
|
||||
setRequests(prev => prev.map(req =>
|
||||
req.request_id === requestId
|
||||
? { ...req, request_no: newTitle }
|
||||
: req
|
||||
));
|
||||
|
||||
// 선택된 요청도 업데이트
|
||||
if (selectedRequest?.request_id === requestId) {
|
||||
setSelectedRequest(prev => ({ ...prev, request_no: newTitle }));
|
||||
}
|
||||
|
||||
setEditingTitle(null);
|
||||
setNewTitle('');
|
||||
console.log('✅ 구매신청 제목 업데이트 완료');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 제목 업데이트 실패:', error);
|
||||
alert('제목 업데이트 실패: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingTitle(null);
|
||||
setNewTitle('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="purchase-request-page">
|
||||
<div className="page-header">
|
||||
<button onClick={() => onNavigate('dashboard')} className="back-btn">
|
||||
← 대시보드로 돌아가기
|
||||
</button>
|
||||
<h1>구매신청 관리</h1>
|
||||
<p className="subtitle">구매신청한 자재들을 그룹별로 관리합니다</p>
|
||||
</div>
|
||||
|
||||
<div className="main-content">
|
||||
{/* 구매신청 목록 */}
|
||||
<div className="requests-panel">
|
||||
<div className="panel-header">
|
||||
<h2>구매신청 목록 ({requests.length})</h2>
|
||||
</div>
|
||||
|
||||
<div className="requests-list">
|
||||
{isLoading ? (
|
||||
<div className="loading">로딩중...</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="empty-state">구매신청이 없습니다</div>
|
||||
) : (
|
||||
requests.map(request => (
|
||||
<div
|
||||
key={request.request_id}
|
||||
className={`request-card ${selectedRequest?.request_id === request.request_id ? 'selected' : ''}`}
|
||||
onClick={() => handleRequestSelect(request)}
|
||||
>
|
||||
<div className="request-header">
|
||||
{editingTitle === request.request_id ? (
|
||||
<div className="title-edit" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="text"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSaveTitle(request.request_id);
|
||||
} else if (e.key === 'Escape') {
|
||||
handleCancelEdit();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: '200px',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveTitle(request.request_id)}
|
||||
style={{
|
||||
marginLeft: '8px',
|
||||
padding: '4px 8px',
|
||||
background: '#10b981',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
style={{
|
||||
marginLeft: '4px',
|
||||
padding: '4px 8px',
|
||||
background: '#6b7280',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="title-display">
|
||||
<span className="request-no">{request.request_no}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditTitle(request);
|
||||
}}
|
||||
style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: 'transparent',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: '#6b7280'
|
||||
}}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<span className="request-date">
|
||||
{new Date(request.requested_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="request-info">
|
||||
<div>{request.job_no} - {request.job_name}</div>
|
||||
<div className="material-count">
|
||||
{request.category || '전체'} | {request.material_count}개 자재
|
||||
</div>
|
||||
</div>
|
||||
<div className="request-footer">
|
||||
<span className="requested-by">{request.requested_by}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownloadExcel(request.request_id, request.request_no);
|
||||
}}
|
||||
className="download-btn"
|
||||
>
|
||||
📥 엑셀
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 구매신청 상세 */}
|
||||
<div className="details-panel">
|
||||
{selectedRequest ? (
|
||||
<>
|
||||
<div className="panel-header">
|
||||
<h2>{selectedRequest.request_no}</h2>
|
||||
<button
|
||||
onClick={() => handleDownloadExcel(selectedRequest.request_id, selectedRequest.request_no)}
|
||||
className="excel-btn"
|
||||
>
|
||||
📥 엑셀 다운로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 원본 파일 정보 */}
|
||||
<div className="original-file-info" style={{
|
||||
background: '#f8fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
📄 원본 파일 정보
|
||||
</h3>
|
||||
<div className="file-details" style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<div className="file-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span className="label" style={{ fontWeight: '500', color: '#6b7280', minWidth: '80px' }}>파일명:</span>
|
||||
<span className="value" style={{ color: '#1f2937' }}>{selectedRequest.original_filename || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="file-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span className="label" style={{ fontWeight: '500', color: '#6b7280', minWidth: '80px' }}>프로젝트:</span>
|
||||
<span className="value" style={{ color: '#1f2937' }}>{selectedRequest.job_no} - {selectedRequest.job_name}</span>
|
||||
</div>
|
||||
<div className="file-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span className="label" style={{ fontWeight: '500', color: '#6b7280', minWidth: '80px' }}>신청일:</span>
|
||||
<span className="value" style={{ color: '#1f2937' }}>{new Date(selectedRequest.requested_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="file-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span className="label" style={{ fontWeight: '500', color: '#6b7280', minWidth: '80px' }}>신청자:</span>
|
||||
<span className="value" style={{ color: '#1f2937' }}>{selectedRequest.requested_by}</span>
|
||||
</div>
|
||||
<div className="file-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span className="label" style={{ fontWeight: '500', color: '#6b7280', minWidth: '80px' }}>자재 수량:</span>
|
||||
<span className="value" style={{ color: '#1f2937', fontWeight: '600' }}>{selectedRequest.material_count}개</span>
|
||||
</div>
|
||||
<div className="file-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span className="label" style={{ fontWeight: '500', color: '#6b7280', minWidth: '80px' }}>카테고리:</span>
|
||||
<span className="value" style={{ color: '#1f2937' }}>{selectedRequest.category || '전체'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="materials-table">
|
||||
{/* 업로드 당시 분류된 정보를 그대로 표시 */}
|
||||
{requestMaterials.length === 0 ? (
|
||||
<div className="empty-state">자재 정보가 없습니다</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* 카테고리별로 그룹화하여 표시 */}
|
||||
{(() => {
|
||||
// 카테고리별로 자재 그룹화
|
||||
const groupedByCategory = requestMaterials.reduce((acc, material) => {
|
||||
const category = material.category || material.classified_category || 'UNCLASSIFIED';
|
||||
if (!acc[category]) acc[category] = [];
|
||||
acc[category].push(material);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.entries(groupedByCategory).map(([category, materials]) => (
|
||||
<div key={category} style={{ marginBottom: '30px' }}>
|
||||
<h3 style={{
|
||||
background: '#f0f0f0',
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{category} ({materials.length}개)
|
||||
</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>No</th>
|
||||
<th>카테고리</th>
|
||||
<th>자재 설명</th>
|
||||
<th>크기</th>
|
||||
<th>스케줄</th>
|
||||
<th>재질</th>
|
||||
<th>수량</th>
|
||||
<th>사용자요구</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{materials.map((material, idx) => (
|
||||
<tr key={material.item_id || material.id || `${category}-${idx}`}>
|
||||
<td>{idx + 1}</td>
|
||||
<td>
|
||||
<span className="category-badge">
|
||||
{material.category || material.classified_category}
|
||||
</span>
|
||||
</td>
|
||||
<td>{material.description || material.original_description}</td>
|
||||
<td>{material.size || material.size_spec || '-'}</td>
|
||||
<td>{material.schedule || '-'}</td>
|
||||
<td>{material.material_grade || material.full_material_grade || '-'}</td>
|
||||
<td>
|
||||
<span style={{ fontWeight: 'bold' }}>
|
||||
{Math.round(material.quantity || material.requested_quantity || 0)} {material.unit || material.requested_unit || '개'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{material.user_requirement || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">📦</div>
|
||||
<div>구매신청을 선택하세요</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseRequestPage;
|
||||
439
tkeg/web/src/pages/SystemLogsPage.jsx
Normal file
439
tkeg/web/src/pages/SystemLogsPage.jsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
import { reportError, logUserActionError } from '../utils/errorLogger';
|
||||
|
||||
const SystemLogsPage = ({ onNavigate, user }) => {
|
||||
const [activeTab, setActiveTab] = useState('login');
|
||||
const [loginLogs, setLoginLogs] = useState([]);
|
||||
const [systemLogs, setSystemLogs] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
|
||||
// 필터 상태
|
||||
const [filters, setFilters] = useState({
|
||||
status: '',
|
||||
level: '',
|
||||
userId: '',
|
||||
limit: 50
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'login') {
|
||||
loadLoginLogs();
|
||||
} else {
|
||||
loadSystemLogs();
|
||||
}
|
||||
}, [activeTab, filters]);
|
||||
|
||||
const loadLoginLogs = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const params = {
|
||||
limit: filters.limit,
|
||||
...(filters.status && { status: filters.status }),
|
||||
...(filters.userId && { user_id: filters.userId })
|
||||
};
|
||||
|
||||
const response = await api.get('/auth/logs/login', { params });
|
||||
|
||||
if (response.data.success) {
|
||||
setLoginLogs(response.data.logs);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: '로그인 로그를 불러올 수 없습니다' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load login logs error:', err);
|
||||
setMessage({ type: 'error', text: '로그인 로그 조회 중 오류가 발생했습니다' });
|
||||
logUserActionError('load_login_logs', err, { userId: user?.user_id });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSystemLogs = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const params = {
|
||||
limit: filters.limit,
|
||||
...(filters.level && { level: filters.level })
|
||||
};
|
||||
|
||||
const response = await api.get('/auth/logs/system', { params });
|
||||
|
||||
if (response.data.success) {
|
||||
setSystemLogs(response.data.logs);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: '시스템 로그를 불러올 수 없습니다' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load system logs error:', err);
|
||||
setMessage({ type: 'error', text: '시스템 로그 조회 중 오류가 발생했습니다' });
|
||||
logUserActionError('load_system_logs', err, { userId: user?.user_id });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const colors = {
|
||||
'success': { bg: '#d1edff', color: '#0c5460' },
|
||||
'failed': { bg: '#f8d7da', color: '#721c24' }
|
||||
};
|
||||
const color = colors[status] || colors.failed;
|
||||
|
||||
return (
|
||||
<span style={{
|
||||
background: color.bg,
|
||||
color: color.color,
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{status === 'success' ? '성공' : '실패'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getLevelBadge = (level) => {
|
||||
const colors = {
|
||||
'ERROR': { bg: '#f8d7da', color: '#721c24' },
|
||||
'WARNING': { bg: '#fff3cd', color: '#856404' },
|
||||
'INFO': { bg: '#d1ecf1', color: '#0c5460' },
|
||||
'DEBUG': { bg: '#e2e3e5', color: '#383d41' }
|
||||
};
|
||||
const color = colors[level] || colors.INFO;
|
||||
|
||||
return (
|
||||
<span style={{
|
||||
background: color.bg,
|
||||
color: color.color,
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
try {
|
||||
return new Date(dateString).toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#f8f9fa' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
padding: '16px 32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#28a745',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
|
||||
onMouseLeave={(e) => e.target.style.background = 'none'}
|
||||
title="대시보드로 돌아가기"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: '700', color: '#2d3748', margin: 0 }}>
|
||||
📊 시스템 로그
|
||||
</h1>
|
||||
<p style={{ color: '#6c757d', fontSize: '14px', margin: '4px 0 0 0' }}>
|
||||
로그인 기록과 시스템 오류 로그를 조회하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '32px', maxWidth: '1400px', margin: '0 auto' }}>
|
||||
{/* 탭 메뉴 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
borderBottom: '2px solid #e9ecef',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setActiveTab('login')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'login' ? '2px solid #007bff' : '2px solid transparent',
|
||||
color: activeTab === 'login' ? '#007bff' : '#6c757d',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
🔐 로그인 로그
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('system')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'system' ? '2px solid #007bff' : '2px solid transparent',
|
||||
color: activeTab === 'system' ? '#007bff' : '#6c757d',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
🖥️ 시스템 로그
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 필터 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{activeTab === 'login' && (
|
||||
<div>
|
||||
<label style={{ fontSize: '14px', fontWeight: '600', color: '#374151', marginRight: '8px' }}>
|
||||
상태:
|
||||
</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value }))}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="success">성공</option>
|
||||
<option value="failed">실패</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'system' && (
|
||||
<div>
|
||||
<label style={{ fontSize: '14px', fontWeight: '600', color: '#374151', marginRight: '8px' }}>
|
||||
레벨:
|
||||
</label>
|
||||
<select
|
||||
value={filters.level}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, level: e.target.value }))}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '14px', fontWeight: '600', color: '#374151', marginRight: '8px' }}>
|
||||
개수:
|
||||
</label>
|
||||
<select
|
||||
value={filters.limit}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, limit: parseInt(e.target.value) }))}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value={50}>50개</option>
|
||||
<option value={100}>100개</option>
|
||||
<option value={200}>200개</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => activeTab === 'login' ? loadLoginLogs() : loadSystemLogs()}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
background: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
🔄 새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메시지 표시 */}
|
||||
{message.text && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '24px',
|
||||
backgroundColor: message.type === 'success' ? '#d1edff' : '#f8d7da',
|
||||
border: `1px solid ${message.type === 'success' ? '#bee5eb' : '#f5c6cb'}`,
|
||||
color: message.type === 'success' ? '#0c5460' : '#721c24'
|
||||
}}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로그 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
background: '#f8f9fa'
|
||||
}}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
|
||||
{activeTab === 'login' ? '로그인 로그' : '시스템 로그'}
|
||||
({activeTab === 'login' ? loginLogs.length : systemLogs.length}개)
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '16px', color: '#6c757d' }}>로딩 중...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflow: 'auto' }}>
|
||||
{activeTab === 'login' ? (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8f9fa' }}>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>시간</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>사용자</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>상태</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>IP 주소</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>실패 사유</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loginLogs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ padding: '40px', textAlign: 'center', color: '#6c757d' }}>
|
||||
로그인 로그가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
loginLogs.map((log, index) => (
|
||||
<tr key={index} style={{ borderBottom: '1px solid #f1f3f4' }}>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#495057' }}>
|
||||
{formatDateTime(log.login_time)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||
{log.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||
@{log.username}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
{getStatusBadge(log.login_status)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#495057' }}>
|
||||
{log.ip_address || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#dc3545' }}>
|
||||
{log.failure_reason || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8f9fa' }}>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>시간</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>레벨</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>모듈</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>메시지</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{systemLogs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} style={{ padding: '40px', textAlign: 'center', color: '#6c757d' }}>
|
||||
시스템 로그가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
systemLogs.map((log, index) => (
|
||||
<tr key={index} style={{ borderBottom: '1px solid #f1f3f4' }}>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#495057' }}>
|
||||
{formatDateTime(log.timestamp)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
{getLevelBadge(log.level)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#495057' }}>
|
||||
{log.module || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#495057', maxWidth: '400px', wordBreak: 'break-word' }}>
|
||||
{log.message}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemLogsPage;
|
||||
664
tkeg/web/src/pages/SystemSettingsPage.jsx
Normal file
664
tkeg/web/src/pages/SystemSettingsPage.jsx
Normal file
@@ -0,0 +1,664 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
const SystemSettingsPage = ({ onNavigate, user }) => {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('users'); // 'users', 'login-logs', 'activity-logs'
|
||||
const [loginLogs, setLoginLogs] = useState([]);
|
||||
const [activityLogs, setActivityLogs] = useState([]);
|
||||
const [newUser, setNewUser] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
full_name: '',
|
||||
role: 'user'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
if (activeTab === 'login-logs') {
|
||||
loadLoginLogs();
|
||||
} else if (activeTab === 'activity-logs') {
|
||||
loadActivityLogs();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/auth/users');
|
||||
if (response.data.success) {
|
||||
setUsers(response.data.users);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('사용자 목록 로딩 실패:', err);
|
||||
setError('사용자 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadLoginLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/auth/logs/login?limit=50');
|
||||
if (response.data.success) {
|
||||
setLoginLogs(response.data.logs);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('로그인 로그 로딩 실패:', err);
|
||||
setError('로그인 로그를 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadActivityLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/auth/logs/system?limit=50');
|
||||
if (response.data.success) {
|
||||
setActivityLogs(response.data.logs);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('활동 로그 로딩 실패:', err);
|
||||
setError('활동 로그를 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateUser = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!newUser.username || !newUser.email || !newUser.password) {
|
||||
setError('모든 필수 필드를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.post('/auth/register', newUser);
|
||||
|
||||
if (response.data.success) {
|
||||
alert('사용자가 성공적으로 생성되었습니다.');
|
||||
setNewUser({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
full_name: '',
|
||||
role: 'user'
|
||||
});
|
||||
setShowCreateForm(false);
|
||||
loadUsers();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('사용자 생성 실패:', err);
|
||||
setError(err.response?.data?.detail || '사용자 생성에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId) => {
|
||||
if (!confirm('정말로 이 사용자를 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.delete(`/auth/users/${userId}`);
|
||||
|
||||
if (response.data.success) {
|
||||
alert('사용자가 삭제되었습니다.');
|
||||
loadUsers();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('사용자 삭제 실패:', err);
|
||||
setError('사용자 삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleDisplay = (role) => {
|
||||
switch (role) {
|
||||
case 'admin': return '관리자';
|
||||
case 'manager': return '매니저';
|
||||
case 'user': return '사용자';
|
||||
default: return role;
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleBadgeColor = (role) => {
|
||||
switch (role) {
|
||||
case 'admin': return '#dc2626';
|
||||
case 'manager': return '#ea580c';
|
||||
case 'user': return '#059669';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
};
|
||||
|
||||
const getActivityTypeColor = (activityType) => {
|
||||
switch (activityType) {
|
||||
case 'FILE_UPLOAD':
|
||||
return '#10b981'; // 초록색
|
||||
case '파일 정보 수정':
|
||||
return '#f59e0b'; // 주황색
|
||||
case '엑셀 내보내기':
|
||||
return '#3b82f6'; // 파란색
|
||||
case '자재 목록 조회':
|
||||
return '#8b5cf6'; // 보라색
|
||||
case 'LOGIN':
|
||||
return '#6b7280'; // 회색
|
||||
default:
|
||||
return '#6b7280';
|
||||
}
|
||||
};
|
||||
|
||||
// 관리자 권한 확인 (system이 최고 권한)
|
||||
if (user?.role !== 'admin' && user?.role !== 'system') {
|
||||
return (
|
||||
<div style={{ padding: '32px', textAlign: 'center' }}>
|
||||
<h2 style={{ color: '#dc2626', marginBottom: '16px' }}>접근 권한이 없습니다</h2>
|
||||
<p style={{ color: '#6b7280', marginBottom: '24px' }}>
|
||||
시스템 설정은 관리자만 접근할 수 있습니다.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
style={{
|
||||
background: '#4299e1',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '12px 24px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
대시보드로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '32px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '28px', fontWeight: '700', color: '#2d3748', marginBottom: '8px' }}>
|
||||
⚙️ 시스템 설정
|
||||
</h1>
|
||||
<p style={{ color: '#718096', fontSize: '16px' }}>
|
||||
사용자 계정 관리 및 시스템 로그 모니터링
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
<div style={{ marginBottom: '24px', borderBottom: '2px solid #e2e8f0' }}>
|
||||
<div style={{ display: 'flex', gap: '0' }}>
|
||||
<button
|
||||
onClick={() => setActiveTab('users')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
border: 'none',
|
||||
background: activeTab === 'users' ? '#4299e1' : 'transparent',
|
||||
color: activeTab === 'users' ? 'white' : '#4a5568',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
cursor: 'pointer',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
borderBottom: activeTab === 'users' ? '2px solid #4299e1' : '2px solid transparent'
|
||||
}}
|
||||
>
|
||||
👥 사용자 관리
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('login-logs')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
border: 'none',
|
||||
background: activeTab === 'login-logs' ? '#4299e1' : 'transparent',
|
||||
color: activeTab === 'login-logs' ? 'white' : '#4a5568',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
cursor: 'pointer',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
borderBottom: activeTab === 'login-logs' ? '2px solid #4299e1' : '2px solid transparent'
|
||||
}}
|
||||
>
|
||||
🔐 로그인 로그
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('activity-logs')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
border: 'none',
|
||||
background: activeTab === 'activity-logs' ? '#4299e1' : 'transparent',
|
||||
color: activeTab === 'activity-logs' ? 'white' : '#4a5568',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
cursor: 'pointer',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
borderBottom: activeTab === 'activity-logs' ? '2px solid #4299e1' : '2px solid transparent'
|
||||
}}
|
||||
>
|
||||
📊 활동 로그
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
style={{
|
||||
background: '#e2e8f0',
|
||||
color: '#4a5568',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '12px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
← 대시보드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fed7d7',
|
||||
color: '#c53030',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 사용자 관리 섹션 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.07)',
|
||||
border: '1px solid #e2e8f0',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748' }}>
|
||||
👥 사용자 관리
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||
style={{
|
||||
background: '#38a169',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
+ 새 사용자 생성
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 사용자 생성 폼 */}
|
||||
{showCreateForm && (
|
||||
<div style={{
|
||||
background: '#f7fafc',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '24px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '600', marginBottom: '16px' }}>
|
||||
새 사용자 생성
|
||||
</h3>
|
||||
<form onSubmit={handleCreateUser}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
사용자명 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newUser.username}
|
||||
onChange={(e) => setNewUser({...newUser, username: e.target.value})}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
이메일 *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={newUser.email}
|
||||
onChange={(e) => setNewUser({...newUser, email: e.target.value})}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
비밀번호 *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newUser.password}
|
||||
onChange={(e) => setNewUser({...newUser, password: e.target.value})}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
전체 이름
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newUser.full_name}
|
||||
onChange={(e) => setNewUser({...newUser, full_name: e.target.value})}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
권한
|
||||
</label>
|
||||
<select
|
||||
value={newUser.role}
|
||||
onChange={(e) => setNewUser({...newUser, role: e.target.value})}
|
||||
style={{
|
||||
width: '200px',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value="user">사용자</option>
|
||||
<option value="manager">매니저</option>
|
||||
<option value="admin">관리자</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: '#38a169',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 16px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
{loading ? '생성 중...' : '사용자 생성'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
style={{
|
||||
background: '#e2e8f0',
|
||||
color: '#4a5568',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 탭별 콘텐츠 */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div style={{ fontSize: '16px', color: '#718096' }}>로딩 중...</div>
|
||||
</div>
|
||||
) : activeTab === 'users' ? (
|
||||
// 사용자 관리 탭
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #e2e8f0' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||
사용자명
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||
이메일
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||
전체 이름
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'center', fontWeight: '600', color: '#4a5568' }}>
|
||||
권한
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'center', fontWeight: '600', color: '#4a5568' }}>
|
||||
상태
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'center', fontWeight: '600', color: '#4a5568' }}>
|
||||
작업
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((userItem) => (
|
||||
<tr key={userItem.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||
<td style={{ padding: '12px', fontWeight: '500' }}>
|
||||
{userItem.username}
|
||||
</td>
|
||||
<td style={{ padding: '12px', color: '#4a5568' }}>
|
||||
{userItem.email}
|
||||
</td>
|
||||
<td style={{ padding: '12px', color: '#4a5568' }}>
|
||||
{userItem.full_name || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
background: getRoleBadgeColor(userItem.role),
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{getRoleDisplay(userItem.role)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
background: userItem.is_active ? '#d1fae5' : '#fee2e2',
|
||||
color: userItem.is_active ? '#065f46' : '#dc2626',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{userItem.is_active ? '활성' : '비활성'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', textAlign: 'center' }}>
|
||||
{userItem.id !== user?.id && (
|
||||
<button
|
||||
onClick={() => handleDeleteUser(userItem.id)}
|
||||
style={{
|
||||
background: '#dc2626',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '6px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : activeTab === 'login-logs' ? (
|
||||
// 로그인 로그 탭
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<h3 style={{ marginBottom: '16px', color: '#2d3748' }}>🔐 로그인 로그</h3>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #e2e8f0' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||
사용자명
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||
IP 주소
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'center', fontWeight: '600', color: '#4a5568' }}>
|
||||
상태
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||
로그인 시간
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loginLogs.map((log, index) => (
|
||||
<tr key={index} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||
<td style={{ padding: '12px', fontWeight: '500' }}>
|
||||
{log.username}
|
||||
</td>
|
||||
<td style={{ padding: '12px', color: '#4a5568' }}>
|
||||
{log.ip_address}
|
||||
</td>
|
||||
<td style={{ padding: '12px', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
background: log.status === 'success' ? '#48bb78' : '#f56565',
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
{log.status === 'success' ? '성공' : '실패'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', color: '#4a5568' }}>
|
||||
{new Date(log.login_time).toLocaleString('ko-KR')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : activeTab === 'activity-logs' ? (
|
||||
// 활동 로그 탭
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<h3 style={{ marginBottom: '16px', color: '#2d3748' }}>📊 활동 로그</h3>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #e2e8f0' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||
사용자명
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||
활동 유형
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||
상세 내용
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||
시간
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activityLogs.map((log, index) => (
|
||||
<tr key={index} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||
<td style={{ padding: '12px', fontWeight: '500' }}>
|
||||
{log.username}
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<span style={{
|
||||
background: getActivityTypeColor(log.activity_type),
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
{log.activity_type}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', color: '#4a5568' }}>
|
||||
{log.activity_description}
|
||||
</td>
|
||||
<td style={{ padding: '12px', color: '#4a5568' }}>
|
||||
{new Date(log.created_at).toLocaleString('ko-KR')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemSettingsPage;
|
||||
|
||||
509
tkeg/web/src/pages/SystemSetupPage.jsx
Normal file
509
tkeg/web/src/pages/SystemSetupPage.jsx
Normal file
@@ -0,0 +1,509 @@
|
||||
import React, { useState } from 'react';
|
||||
import api from '../api';
|
||||
import { reportError } from '../utils/errorLogger';
|
||||
|
||||
const SystemSetupPage = ({ onSetupComplete }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
name: '',
|
||||
email: '',
|
||||
department: '',
|
||||
position: ''
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [validationErrors, setValidationErrors] = useState({});
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// 입력 시 해당 필드의 에러 메시지 초기화
|
||||
if (validationErrors[name]) {
|
||||
setValidationErrors(prev => ({
|
||||
...prev,
|
||||
[name]: ''
|
||||
}));
|
||||
}
|
||||
if (error) setError('');
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const errors = {};
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!formData.username.trim()) {
|
||||
errors.username = '사용자명을 입력해주세요';
|
||||
} else if (formData.username.length < 3 || formData.username.length > 20) {
|
||||
errors.username = '사용자명은 3-20자여야 합니다';
|
||||
} else if (!/^[a-zA-Z0-9_]+$/.test(formData.username)) {
|
||||
errors.username = '사용자명은 영문, 숫자, 언더스코어만 사용 가능합니다';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
errors.password = '비밀번호를 입력해주세요';
|
||||
} else if (formData.password.length < 8) {
|
||||
errors.password = '비밀번호는 8자 이상이어야 합니다';
|
||||
}
|
||||
|
||||
if (!formData.confirmPassword) {
|
||||
errors.confirmPassword = '비밀번호 확인을 입력해주세요';
|
||||
} else if (formData.password !== formData.confirmPassword) {
|
||||
errors.confirmPassword = '비밀번호가 일치하지 않습니다';
|
||||
}
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
errors.name = '이름을 입력해주세요';
|
||||
} else if (formData.name.length < 2 || formData.name.length > 50) {
|
||||
errors.name = '이름은 2-50자여야 합니다';
|
||||
}
|
||||
|
||||
// 이메일 검증 (선택사항)
|
||||
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
errors.email = '올바른 이메일 형식을 입력해주세요';
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const setupData = {
|
||||
username: formData.username.trim(),
|
||||
password: formData.password,
|
||||
name: formData.name.trim(),
|
||||
email: formData.email.trim() || null,
|
||||
department: formData.department.trim() || null,
|
||||
position: formData.position.trim() || null
|
||||
};
|
||||
|
||||
const response = await api.post('/setup/initialize', setupData);
|
||||
|
||||
if (response.data.success) {
|
||||
// 설정 완료 후 콜백 호출
|
||||
if (onSetupComplete) {
|
||||
onSetupComplete(response.data);
|
||||
}
|
||||
} else {
|
||||
setError(response.data.message || '시스템 초기화에 실패했습니다');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('System setup error:', err);
|
||||
|
||||
const errorMessage = err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
'시스템 초기화 중 오류가 발생했습니다';
|
||||
|
||||
setError(errorMessage);
|
||||
|
||||
// 오류 로깅
|
||||
reportError('System setup failed', {
|
||||
error: err.message,
|
||||
response: err.response?.data,
|
||||
formData: { ...formData, password: '[HIDDEN]', confirmPassword: '[HIDDEN]' }
|
||||
});
|
||||
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '20px'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '500px',
|
||||
width: '100%',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.1)',
|
||||
padding: '40px'
|
||||
}}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🚀</div>
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
시스템 초기 설정
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#718096',
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
TK-MP 시스템을 처음 사용하시는군요!<br />
|
||||
시스템 관리자 계정을 생성해주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 폼 */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* 사용자명 */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
사용자명 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
placeholder="영문, 숫자, 언더스코어 (3-20자)"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: validationErrors.username ? '2px solid #ef4444' : '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = validationErrors.username ? '#ef4444' : '#d1d5db'}
|
||||
/>
|
||||
{validationErrors.username && (
|
||||
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
|
||||
{validationErrors.username}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이름 */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
이름 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="실제 이름을 입력해주세요"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: validationErrors.name ? '2px solid #ef4444' : '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = validationErrors.name ? '#ef4444' : '#d1d5db'}
|
||||
/>
|
||||
{validationErrors.name && (
|
||||
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
|
||||
{validationErrors.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
비밀번호 *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
placeholder="8자 이상 입력해주세요"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: validationErrors.password ? '2px solid #ef4444' : '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = validationErrors.password ? '#ef4444' : '#d1d5db'}
|
||||
/>
|
||||
{validationErrors.password && (
|
||||
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
|
||||
{validationErrors.password}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 확인 */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
비밀번호 확인 *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
placeholder="비밀번호를 다시 입력해주세요"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: validationErrors.confirmPassword ? '2px solid #ef4444' : '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = validationErrors.confirmPassword ? '#ef4444' : '#d1d5db'}
|
||||
/>
|
||||
{validationErrors.confirmPassword && (
|
||||
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
|
||||
{validationErrors.confirmPassword}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이메일 (선택사항) */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
이메일 (선택사항)
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="admin@company.com"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: validationErrors.email ? '2px solid #ef4444' : '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = validationErrors.email ? '#ef4444' : '#d1d5db'}
|
||||
/>
|
||||
{validationErrors.email && (
|
||||
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
|
||||
{validationErrors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 부서/직책 (선택사항) */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
부서 (선택사항)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="department"
|
||||
value={formData.department}
|
||||
onChange={handleChange}
|
||||
placeholder="IT팀"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = '#d1d5db'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
직책 (선택사항)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="position"
|
||||
value={formData.position}
|
||||
onChange={handleChange}
|
||||
placeholder="시스템 관리자"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = '#d1d5db'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div style={{
|
||||
backgroundColor: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<p style={{ color: '#dc2626', fontSize: '14px', margin: 0 }}>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 제출 버튼 */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px',
|
||||
backgroundColor: isLoading ? '#9ca3af' : '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
transition: 'background-color 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isLoading) e.target.style.backgroundColor = '#2563eb';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isLoading) e.target.style.backgroundColor = '#3b82f6';
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '2px solid #ffffff',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
설정 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
🚀 시스템 초기화
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 안내 메시지 */}
|
||||
<div style={{
|
||||
marginTop: '24px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#f0f9ff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #bae6fd'
|
||||
}}>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#0369a1',
|
||||
margin: 0,
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
💡 <strong>안내:</strong> 시스템 관리자는 모든 권한을 가지며, 다른 사용자 계정을 생성하고 관리할 수 있습니다.
|
||||
설정 완료 후 이 계정으로 로그인하여 추가 사용자를 생성하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CSS 애니메이션 */}
|
||||
<style jsx>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemSetupPage;
|
||||
266
tkeg/web/src/pages/UnifiedBOMPage.jsx
Normal file
266
tkeg/web/src/pages/UnifiedBOMPage.jsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import BOMUploadTab from '../components/bom/tabs/BOMUploadTab';
|
||||
import BOMFilesTab from '../components/bom/tabs/BOMFilesTab';
|
||||
import BOMMaterialsTab from '../components/bom/tabs/BOMMaterialsTab';
|
||||
|
||||
const UnifiedBOMPage = ({
|
||||
onNavigate,
|
||||
selectedProject,
|
||||
user
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState('upload');
|
||||
const [selectedBOM, setSelectedBOM] = useState(null);
|
||||
const [bomFiles, setBomFiles] = useState([]);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
// 업로드 성공 시 Files 탭으로 이동
|
||||
const handleUploadSuccess = (uploadedFile) => {
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
setActiveTab('files');
|
||||
// 업로드된 파일을 자동 선택
|
||||
if (uploadedFile) {
|
||||
setSelectedBOM(uploadedFile);
|
||||
}
|
||||
};
|
||||
|
||||
// BOM 파일 선택 시 Materials 탭으로 이동
|
||||
const handleBOMSelect = (bomFile) => {
|
||||
setSelectedBOM(bomFile);
|
||||
setActiveTab('materials');
|
||||
};
|
||||
|
||||
// 탭 정의
|
||||
const tabs = [
|
||||
{
|
||||
id: 'upload',
|
||||
label: 'Upload',
|
||||
icon: '📤',
|
||||
description: 'Upload new BOM files'
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
label: 'Files & Revisions',
|
||||
icon: '📊',
|
||||
description: 'Manage BOM files and revisions'
|
||||
},
|
||||
{
|
||||
id: 'materials',
|
||||
label: 'Materials',
|
||||
icon: '📋',
|
||||
description: 'Manage classified materials',
|
||||
disabled: !selectedBOM
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '40px',
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
|
||||
minHeight: '100vh',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
|
||||
}}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '20px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
marginBottom: '40px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0',
|
||||
letterSpacing: '-0.025em'
|
||||
}}>
|
||||
BOM Management System
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#64748b',
|
||||
margin: 0,
|
||||
fontWeight: '400'
|
||||
}}>
|
||||
Project: {selectedProject?.job_name || 'No Project Selected'}
|
||||
{selectedBOM && (
|
||||
<span style={{ marginLeft: '16px', color: '#3b82f6', fontWeight: '500' }}>
|
||||
→ {selectedBOM.bom_name || selectedBOM.original_filename}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 정보 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '20px'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#1d4ed8', marginBottom: '4px' }}>
|
||||
{selectedProject?.official_project_code || selectedProject?.job_no || 'N/A'}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1d4ed8', fontWeight: '500' }}>
|
||||
Project Code
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#059669', marginBottom: '4px' }}>
|
||||
{user?.username || 'Unknown'}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#059669', fontWeight: '500' }}>
|
||||
Current User
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#d97706', marginBottom: '4px' }}>
|
||||
{bomFiles.length}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#d97706', fontWeight: '500' }}>
|
||||
BOM Files
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '20px',
|
||||
padding: '24px 32px',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
marginBottom: '40px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => !tab.disabled && setActiveTab(tab.id)}
|
||||
disabled={tab.disabled}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '16px 24px',
|
||||
background: activeTab === tab.id
|
||||
? 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)'
|
||||
: tab.disabled
|
||||
? '#f3f4f6'
|
||||
: 'white',
|
||||
color: activeTab === tab.id
|
||||
? 'white'
|
||||
: tab.disabled
|
||||
? '#9ca3af'
|
||||
: '#374151',
|
||||
border: activeTab === tab.id
|
||||
? 'none'
|
||||
: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
cursor: tab.disabled ? 'not-allowed' : 'pointer',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease',
|
||||
textAlign: 'center',
|
||||
opacity: tab.disabled ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '20px', marginBottom: '4px' }}>
|
||||
{tab.icon}
|
||||
</div>
|
||||
<div>{tab.label}</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '400',
|
||||
marginTop: '4px',
|
||||
opacity: 0.8
|
||||
}}>
|
||||
{tab.description}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 컨텐츠 */}
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '20px',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{activeTab === 'upload' && (
|
||||
<BOMUploadTab
|
||||
selectedProject={selectedProject}
|
||||
user={user}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'files' && (
|
||||
<BOMFilesTab
|
||||
selectedProject={selectedProject}
|
||||
user={user}
|
||||
bomFiles={bomFiles}
|
||||
setBomFiles={setBomFiles}
|
||||
selectedBOM={selectedBOM}
|
||||
onBOMSelect={handleBOMSelect}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'materials' && selectedBOM && (
|
||||
<BOMMaterialsTab
|
||||
selectedProject={selectedProject}
|
||||
user={user}
|
||||
selectedBOM={selectedBOM}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnifiedBOMPage;
|
||||
Reference in New Issue
Block a user