feat(tkeg): tkeg BOM 자재관리 서비스 초기 세팅 (api + web + docker-compose)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-16 15:41:58 +09:00
parent 2699242d1f
commit 1e1d2f631a
160 changed files with 60367 additions and 0 deletions

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

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

View File

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

View File

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

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

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

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

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

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

View 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>&copy; 2025 Technical Korea. All rights reserved.</p>
</footer>
</div>
</div>
);
};
export default MainPage;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

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

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

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

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

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

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