feat: SWG 가스켓 전체 구성 정보 표시 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- H/F/I/O SS304/GRAPHITE/CS/CS 패턴에서 4개 구성요소 모두 표시
- 기존 SS304 + GRAPHITE → SS304/GRAPHITE/CS/CS로 완전한 구성 표시
- 외부링/필러/내부링/추가구성 모든 정보 포함
- 구매수량 계산 모달에서 정확한 재질 정보 확인 가능
This commit is contained in:
Hyungi Ahn
2025-08-30 14:23:01 +09:00
parent 78d90c7a8f
commit 4f8e395f87
84 changed files with 16297 additions and 2161 deletions

View File

@@ -0,0 +1,431 @@
import React, { useState, useEffect } from 'react';
import SimpleFileUpload from '../components/SimpleFileUpload';
import MaterialList from '../components/MaterialList';
import { fetchMaterials, fetchFiles } from '../api';
const BOMManagementPage = ({ user }) => {
const [activeTab, setActiveTab] = useState('upload');
const [projects, setProjects] = useState([]);
const [selectedProject, setSelectedProject] = useState(null);
const [files, setFiles] = useState([]);
const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState({
totalFiles: 0,
totalMaterials: 0,
recentUploads: []
});
useEffect(() => {
loadProjects();
}, []);
useEffect(() => {
if (selectedProject) {
loadProjectFiles();
}
}, [selectedProject]);
useEffect(() => {
loadStats();
}, [files, materials]);
const loadProjects = async () => {
try {
const response = await fetch('/api/jobs/');
const data = await response.json();
if (data.success) {
setProjects(data.jobs);
}
} catch (error) {
console.error('프로젝트 로딩 실패:', error);
}
};
const loadProjectFiles = async () => {
if (!selectedProject) return;
setLoading(true);
try {
// 기존 API 함수 사용 - 파일 목록 로딩
const filesResponse = await fetchFiles({ job_no: selectedProject.job_no });
setFiles(Array.isArray(filesResponse.data) ? filesResponse.data : []);
// 기존 API 함수 사용 - 자재 목록 로딩
const materialsResponse = await fetchMaterials({ job_no: selectedProject.job_no, limit: 1000 });
setMaterials(materialsResponse.data?.materials || []);
} catch (error) {
console.error('프로젝트 데이터 로딩 실패:', error);
} finally {
setLoading(false);
}
};
const loadStats = async () => {
try {
// 실제 통계 계산 - 더미 데이터 없이
const totalFiles = files.length;
const totalMaterials = materials.length;
setStats({
totalFiles,
totalMaterials,
recentUploads: files.slice(0, 5) // 최근 5개 파일
});
} catch (error) {
console.error('통계 로딩 실패:', error);
}
};
const handleFileUpload = async (uploadData) => {
try {
setLoading(true);
// 기존 FileUpload 컴포넌트의 업로드 로직 활용
await loadProjectFiles(); // 업로드 후 데이터 새로고침
await loadStats();
} catch (error) {
console.error('파일 업로드 후 새로고침 실패:', error);
} finally {
setLoading(false);
}
};
const StatCard = ({ title, value, icon, color = '#667eea' }) => (
<div style={{
background: 'white',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
border: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'center',
gap: '16px'
}}>
<div style={{
width: '48px',
height: '48px',
borderRadius: '12px',
background: color + '20',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '24px'
}}>
{icon}
</div>
<div>
<div style={{
fontSize: '24px',
fontWeight: '700',
color: '#2d3748',
marginBottom: '4px'
}}>
{value}
</div>
<div style={{
fontSize: '14px',
color: '#718096'
}}>
{title}
</div>
</div>
</div>
);
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ marginBottom: '32px' }}>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
🔧 BOM 관리
</h1>
<p style={{
color: '#718096',
fontSize: '16px',
margin: '0'
}}>
Bill of Materials 업로드, 분석 관리를 수행하세요.
</p>
</div>
{/* 통계 카드들 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '20px',
marginBottom: '32px'
}}>
<StatCard
title="총 업로드 파일"
value={stats.totalFiles}
icon="📄"
color="#667eea"
/>
<StatCard
title="분석된 자재"
value={stats.totalMaterials}
icon="🔧"
color="#48bb78"
/>
<StatCard
title="활성 프로젝트"
value={projects.length}
icon="📋"
color="#ed8936"
/>
</div>
{/* 탭 네비게이션 */}
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
overflow: 'hidden',
marginBottom: '24px'
}}>
<div style={{
display: 'flex',
borderBottom: '1px solid #e2e8f0'
}}>
{[
{ id: 'upload', label: '📤 파일 업로드', icon: '📤' },
{ id: 'files', label: '📁 파일 관리', icon: '📁' },
{ id: 'materials', label: '🔧 자재 목록', icon: '🔧' },
{ id: 'analysis', label: '📊 분석 결과', icon: '📊' }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
style={{
flex: 1,
padding: '16px 20px',
background: activeTab === tab.id ? '#f7fafc' : 'transparent',
border: 'none',
borderBottom: activeTab === tab.id ? '2px solid #667eea' : '2px solid transparent',
cursor: 'pointer',
fontSize: '14px',
fontWeight: activeTab === tab.id ? '600' : '500',
color: activeTab === tab.id ? '#667eea' : '#4a5568',
transition: 'all 0.2s ease'
}}
>
{tab.label}
</button>
))}
</div>
{/* 탭 콘텐츠 */}
<div style={{ padding: '24px' }}>
{activeTab === 'upload' && (
<div>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 20px 0'
}}>
📤 BOM 파일 업로드
</h3>
{/* 프로젝트 선택 */}
<div style={{ marginBottom: '24px' }}>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '8px'
}}>
프로젝트 선택
</label>
<select
value={selectedProject?.job_no || ''}
onChange={(e) => {
const project = projects.find(p => p.job_no === e.target.value);
setSelectedProject(project);
}}
style={{
width: '100%',
padding: '12px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
fontSize: '14px',
background: 'white'
}}
>
<option value="">프로젝트를 선택하세요</option>
{projects.map(project => (
<option key={project.job_no} value={project.job_no}>
{project.job_no} - {project.job_name}
</option>
))}
</select>
</div>
{selectedProject ? (
<div>
<div style={{
background: '#f7fafc',
border: '1px solid #e2e8f0',
borderRadius: '8px',
padding: '16px',
marginBottom: '24px'
}}>
<h4 style={{ margin: '0 0 8px 0', color: '#2d3748' }}>
선택된 프로젝트: {selectedProject.job_name}
</h4>
<p style={{ margin: '0', fontSize: '14px', color: '#718096' }}>
Job No: {selectedProject.job_no} |
고객사: {selectedProject.client_name} |
상태: {selectedProject.status}
</p>
</div>
<SimpleFileUpload
selectedProject={selectedProject}
onUploadComplete={handleFileUpload}
/>
</div>
) : (
<div style={{
textAlign: 'center',
padding: '40px',
color: '#718096'
}}>
먼저 프로젝트를 선택해주세요.
</div>
)}
</div>
)}
{activeTab === 'files' && (
<div>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 20px 0'
}}>
📁 업로드된 파일 목록
</h3>
{selectedProject ? (
loading ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
파일 목록을 불러오는 ...
</div>
) : files.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{files.map((file, index) => (
<div key={index} style={{
background: '#f7fafc',
border: '1px solid #e2e8f0',
borderRadius: '8px',
padding: '16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div>
<div style={{ fontWeight: '600', color: '#2d3748' }}>
{file.original_filename || file.filename}
</div>
<div style={{ fontSize: '12px', color: '#718096' }}>
업로드: {new Date(file.created_at).toLocaleString()} |
자재 : {file.parsed_count || 0}
</div>
</div>
<div style={{
padding: '4px 8px',
background: '#48bb78',
color: 'white',
borderRadius: '4px',
fontSize: '12px'
}}>
{file.revision || 'Rev.0'}
</div>
</div>
))}
</div>
) : (
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
업로드된 파일이 없습니다.
</div>
)
) : (
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
프로젝트를 선택해주세요.
</div>
)}
</div>
)}
{activeTab === 'materials' && (
<div>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 20px 0'
}}>
🔧 자재 목록
</h3>
{selectedProject ? (
<MaterialList
selectedProject={selectedProject}
key={selectedProject.job_no} // 프로젝트 변경 컴포넌트 재렌더링
/>
) : (
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
프로젝트를 선택해주세요.
</div>
)}
</div>
)}
{activeTab === 'analysis' && (
<div>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 20px 0'
}}>
📊 분석 결과
</h3>
<div style={{
background: '#fff3cd',
border: '1px solid #ffeaa7',
borderRadius: '8px',
padding: '16px',
textAlign: 'center'
}}>
<div style={{ fontSize: '16px', color: '#856404' }}>
🚧 분석 결과 페이지는 구현될 예정입니다.
</div>
<div style={{ fontSize: '14px', color: '#856404', marginTop: '8px' }}>
자재 분류, 통계, 비교 분석 기능이 추가됩니다.
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default BOMManagementPage;

View File

@@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react';
import { Box, Typography, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, CircularProgress, Alert, TextField, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { uploadFile as uploadFileApi, fetchFiles as fetchFilesApi, deleteFile as deleteFileApi } from '../api';
import { uploadFile as uploadFileApi, fetchFiles as fetchFilesApi, deleteFile as deleteFileApi, api } from '../api';
import BOMFileUpload from '../components/BOMFileUpload';
import BOMFileTable from '../components/BOMFileTable';
import RevisionUploadDialog from '../components/RevisionUploadDialog';
const BOMStatusPage = () => {
const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -12,10 +13,24 @@ const BOMStatusPage = () => {
const [bomName, setBomName] = useState('');
const [revisionDialog, setRevisionDialog] = useState({ open: false, bomName: '', parentId: null });
const [revisionFile, setRevisionFile] = useState(null);
const [searchParams] = useSearchParams();
const jobNo = searchParams.get('job_no');
const jobName = searchParams.get('job_name');
const navigate = useNavigate();
const [purchaseModal, setPurchaseModal] = useState({ open: false, data: null, fileInfo: null });
// 카테고리별 색상 함수
const getCategoryColor = (category) => {
const colors = {
'pipe': '#4299e1',
'fitting': '#48bb78',
'valve': '#ed8936',
'flange': '#9f7aea',
'bolt': '#38b2ac',
'gasket': '#f56565',
'instrument': '#d69e2e',
'material': '#718096',
'integrated': '#319795',
'unknown': '#a0aec0'
};
return colors[category?.toLowerCase()] || colors.unknown;
};
// 파일 목록 불러오기
const fetchFiles = async () => {
@@ -26,134 +41,167 @@ const BOMStatusPage = () => {
const response = await fetchFilesApi({ job_no: jobNo });
console.log('API 응답:', response);
if (Array.isArray(response.data)) {
console.log('데이터 배열 형태:', response.data.length, '개');
if (response.data && response.data.data && Array.isArray(response.data.data)) {
setFiles(response.data.data);
} else if (response.data && Array.isArray(response.data)) {
setFiles(response.data);
} else if (response.data && Array.isArray(response.data.files)) {
console.log('데이터.files 배열 형태:', response.data.files.length, '개');
setFiles(response.data.files);
} else {
console.log('빈 배열로 설정');
setFiles([]);
}
} catch (e) {
setError('파일 목록 불러오지 못했습니다.');
console.error('파일 목록 로드 에러:', e);
} catch (err) {
console.error('파일 목록 불러오기 실패:', err);
setError('파일 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
useEffect(() => {
console.log('useEffect 실행 - jobNo:', jobNo);
if (jobNo) {
fetchFiles();
} else {
console.log('jobNo가 없어서 fetchFiles 실행하지 않음');
}
// eslint-disable-next-line
}, [jobNo]);
// BOM 이름 중복 체크
const checkDuplicateBOM = () => {
return files.some(file =>
file.bom_name === bomName ||
file.original_filename === bomName ||
file.filename === bomName
);
};
// 파일 업로드 핸들러
// 파일 업로드
const handleUpload = async () => {
if (!selectedFile) {
setError('파일을 선택해주세요.');
if (!selectedFile || !bomName.trim()) {
setError('파일과 BOM 이름을 모두 입력해주세요.');
return;
}
if (!bomName.trim()) {
setError('BOM 이름을 입력해주세요.');
return;
}
setUploading(true);
setError('');
try {
const isDuplicate = checkDuplicateBOM();
if (isDuplicate && !confirm(`"${bomName}"은(는) 이미 존재하는 BOM입니다. 새로운 리비전으로 업로드하시겠습니까?`)) {
setUploading(false);
return;
}
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('job_no', jobNo);
formData.append('revision', 'Rev.0');
formData.append('bom_name', bomName);
formData.append('bom_type', 'excel');
formData.append('description', '');
formData.append('bom_name', bomName.trim());
const uploadResult = await uploadFileApi(formData);
const response = await uploadFileApi(formData);
// 업로드 성공 후 파일 목록 새로고침
await fetchFiles();
if (response.data.success) {
setSelectedFile(null);
setBomName('');
// 파일 input 초기화
const fileInput = document.getElementById('file-input');
if (fileInput) fileInput.value = '';
fetchFiles();
alert(`업로드 성공! ${response.data.materials_count || 0}개의 자재가 분류되었습니다.\n리비전: ${response.data.revision || 'Rev.0'}`);
} else {
setError(response.data.message || '업로드에 실패했습니다.');
}
} catch (e) {
console.error('업로드 에러:', e);
if (e.response?.data?.detail) {
setError(e.response.data.detail);
} else {
setError('파일 업로드에 실패했습니다.');
// 업로드 완료 후 자동으로 구매 수량 계산 실행
if (uploadResult && uploadResult.file_id) {
// 잠시 후 구매 수량 계산 페이지로 이동
setTimeout(async () => {
try {
// 구매 수량 계산 API 호출
const response = await fetch(`/api/purchase/calculate?job_no=${jobNo}&revision=Rev.0&file_id=${uploadResult.file_id}`);
const purchaseData = await response.json();
if (purchaseData.success) {
// 구매 수량 계산 결과를 모달로 표시하거나 별도 페이지로 이동
alert(`업로드 및 분류 완료!\n구매 수량이 계산되었습니다.\n\n파이프: ${purchaseData.purchase_items?.filter(item => item.category === 'PIPE').length || 0}개 항목\n기타 자재: ${purchaseData.purchase_items?.filter(item => item.category !== 'PIPE').length || 0}개 항목`);
}
} catch (error) {
console.error('구매 수량 계산 실패:', error);
}
}, 2000); // 2초 후 실행 (분류 완료 대기)
}
// 폼 초기화
setSelectedFile(null);
setBomName('');
document.getElementById('file-input').value = '';
} catch (err) {
console.error('파일 업로드 실패:', err);
setError('파일 업로드에 실패했습니다.');
} finally {
setUploading(false);
}
};
// 리비전 업로드 핸들러
// 파일 삭제
const handleDelete = async (fileId) => {
if (!window.confirm('정말로 이 파일을 삭제하시겠습니까?')) {
return;
}
try {
await deleteFileApi(fileId);
await fetchFiles(); // 목록 새로고침
} catch (err) {
console.error('파일 삭제 실패:', err);
setError('파일 삭제에 실패했습니다.');
}
};
// 자재 확인 페이지로 이동
// 구매 수량 계산 (자재 목록 페이지 거치지 않음)
const handleViewMaterials = async (file) => {
try {
setLoading(true);
// 구매 수량 계산 API 호출
console.log('구매 수량 계산 API 호출:', {
job_no: file.job_no,
revision: file.revision || 'Rev.0',
file_id: file.id
});
const response = await api.get(`/purchase/items/calculate?job_no=${file.job_no}&revision=${file.revision || 'Rev.0'}&file_id=${file.id}`);
console.log('구매 수량 계산 응답:', response.data);
const purchaseData = response.data;
if (purchaseData.success && purchaseData.items) {
// 구매 수량 계산 결과를 모달로 표시
setPurchaseModal({
open: true,
data: purchaseData.items,
fileInfo: file
});
} else {
alert('구매 수량 계산에 실패했습니다.');
}
} catch (error) {
console.error('구매 수량 계산 오류:', error);
alert('구매 수량 계산 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
// 리비전 업로드 다이얼로그 열기
const openRevisionDialog = (bomName, parentId) => {
setRevisionDialog({ open: true, bomName, parentId });
};
// 리비전 업로드
const handleRevisionUpload = async () => {
if (!revisionFile) {
if (!revisionFile || !revisionDialog.bomName) {
setError('파일을 선택해주세요.');
return;
}
setUploading(true);
setError('');
try {
const formData = new FormData();
formData.append('file', revisionFile);
formData.append('job_no', jobNo);
formData.append('revision', 'Rev.0'); // 백엔드에서 자동 증가
formData.append('bom_name', revisionDialog.bomName);
formData.append('parent_file_id', revisionDialog.parentId);
formData.append('parent_id', revisionDialog.parentId);
await uploadFileApi(formData);
const response = await uploadFileApi(formData);
// 업로드 성공 후 파일 목록 새로고침
await fetchFiles();
if (response.data.success) {
setRevisionDialog({ open: false, bomName: '', parentId: null });
setRevisionFile(null);
fetchFiles();
alert(`리비전 업로드 성공! ${response.data.materials_count || 0}개의 자재가 분류되었습니다.\n리비전: ${response.data.revision}`);
} else {
setError(response.data.message || '리비전 업로드에 실패했습니다.');
}
} catch (e) {
console.error('리비전 업로드 에러:', e);
if (e.response?.data?.detail) {
setError(e.response.data.detail);
} else {
setError('리비전 업로드에 실패했습니다.');
}
// 다이얼로그 닫기
setRevisionDialog({ open: false, bomName: '', parentId: null });
setRevisionFile(null);
} catch (err) {
console.error('리비전 업로드 실패:', err);
setError('리비전 업로드에 실패했습니다.');
} finally {
setUploading(false);
}
@@ -183,236 +231,274 @@ const BOMStatusPage = () => {
};
return (
<Box sx={{ maxWidth: 900, mx: 'auto', mt: 4 }}>
<Button variant="outlined" onClick={() => navigate('/')} sx={{ mb: 2 }}>
뒤로가기
</Button>
<Typography variant="h4" gutterBottom>📊 BOM 관리 시스템</Typography>
{jobNo && jobName && (
<Typography variant="h6" color="primary" sx={{ mb: 3 }}>
{jobNo} - {jobName}
</Typography>
)}
{/* 파일 업로드 폼 */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}> BOM 업로드</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="BOM 이름"
value={bomName}
onChange={(e) => setBomName(e.target.value)}
placeholder="예: PIPING_BOM_A구역"
required
size="small"
helperText="동일한 BOM 이름으로 재업로드 시 리비전이 자동 증가합니다"
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<input
id="file-input"
type="file"
accept=".csv,.xlsx,.xls"
onChange={(e) => setSelectedFile(e.target.files[0])}
style={{ flex: 1 }}
/>
<Button
variant="contained"
onClick={handleUpload}
disabled={!selectedFile || !bomName.trim() || uploading}
>
{uploading ? '업로드 중...' : '업로드'}
</Button>
</Box>
{selectedFile && (
<Typography variant="body2" color="textSecondary">
선택된 파일: {selectedFile.name}
</Typography>
)}
</Box>
</Paper>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Typography variant="h6" sx={{ mt: 4, mb: 2 }}>업로드된 BOM 목록</Typography>
{loading && <CircularProgress />}
{!loading && files.length === 0 && (
<Alert severity="info">업로드된 BOM이 없습니다.</Alert>
)}
{!loading && files.length > 0 && (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>BOM 이름</TableCell>
<TableCell>파일명</TableCell>
<TableCell>리비전</TableCell>
<TableCell>자재 </TableCell>
<TableCell>업로드 일시</TableCell>
<TableCell>작업</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(groupFilesByBOM()).map(([bomKey, bomFiles]) => (
bomFiles.map((file, index) => (
<TableRow key={file.id} sx={{
backgroundColor: index === 0 ? 'rgba(25, 118, 210, 0.08)' : 'rgba(0, 0, 0, 0.02)'
}}>
<TableCell>
<Typography variant="body2" fontWeight={index === 0 ? 'bold' : 'normal'}>
{file.bom_name || bomKey}
</Typography>
{index === 0 && bomFiles.length > 1 && (
<Typography variant="caption" color="primary">
(최신 리비전)
</Typography>
)}
{index > 0 && (
<Typography variant="caption" color="textSecondary">
(이전 버전)
</Typography>
)}
</TableCell>
<TableCell>
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
{file.filename || file.original_filename}
</Typography>
</TableCell>
<TableCell>
<Typography
variant="body2"
color={index === 0 ? 'primary' : 'textSecondary'}
fontWeight={index === 0 ? 'bold' : 'normal'}
>
{file.revision || 'Rev.0'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
{file.parsed_count || 0}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
{file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'}
</Typography>
</TableCell>
<TableCell>
<Button
size="small"
variant={index === 0 ? "contained" : "outlined"}
onClick={() => navigate(`/materials?file_id=${file.id}&job_no=${jobNo}&filename=${encodeURIComponent(file.filename || file.original_filename)}`)}
sx={{ mr: 1 }}
>
자재확인
</Button>
{index === 0 && (
<Button
size="small"
variant="outlined"
color="primary"
onClick={() => setRevisionDialog({
open: true,
bomName: file.bom_name || bomKey,
parentId: file.id
})}
sx={{ mr: 1 }}
>
리비전
</Button>
)}
{file.revision !== 'Rev.0' && index < 3 && (
<>
<Button
size="small"
variant="outlined"
color="secondary"
onClick={() => navigate(`/material-comparison?job_no=${jobNo}&revision=${file.revision}&filename=${encodeURIComponent(file.original_filename)}`)}
sx={{ mr: 1 }}
>
비교
</Button>
<Button
size="small"
variant="outlined"
color="success"
onClick={() => navigate(`/revision-purchase?job_no=${jobNo}&current_revision=${file.revision}&bom_name=${encodeURIComponent(file.bom_name || bomKey)}`)}
sx={{ mr: 1 }}
>
구매 필요
</Button>
</>
)}
<Button
size="small"
color="error"
onClick={async () => {
if (confirm(`정말 "${file.revision || 'Rev.0'}"을 삭제하시겠습니까?`)) {
try {
const response = await deleteFileApi(file.id);
if (response.data.success) {
fetchFiles();
alert('삭제되었습니다.');
} else {
alert('삭제 실패: ' + (response.data.message || '알 수 없는 오류'));
}
} catch (e) {
console.error('삭제 오류:', e);
alert('삭제 중 오류가 발생했습니다.');
}
}
}}
>
삭제
</Button>
</TableCell>
</TableRow>
))
))}
</TableBody>
</Table>
</TableContainer>
)}
{/* 리비전 업로드 다이얼로그 */}
<Dialog open={revisionDialog.open} onClose={() => setRevisionDialog({ open: false, bomName: '', parentId: null })}>
<DialogTitle>리비전 업로드</DialogTitle>
<DialogContent>
<Typography variant="body1" sx={{ mb: 2 }}>
BOM 이름: <strong>{revisionDialog.bomName}</strong>
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
새로운 리비전 파일을 선택하세요. 리비전 번호는 자동으로 증가합니다.
</Typography>
<input
type="file"
accept=".csv,.xlsx,.xls"
onChange={(e) => setRevisionFile(e.target.files[0])}
style={{ marginTop: 16 }}
/>
{revisionFile && (
<Typography variant="body2" sx={{ mt: 1 }}>
선택된 파일: {revisionFile.name}
</Typography>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => {
setRevisionDialog({ open: false, bomName: '', parentId: null });
setRevisionFile(null);
}}>
취소
</Button>
<Button
variant="contained"
onClick={handleRevisionUpload}
disabled={!revisionFile || uploading}
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ marginBottom: '24px' }}>
<button
onClick={() => onNavigate && onNavigate('bom')}
style={{
padding: '8px 16px',
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: '6px',
cursor: 'pointer',
marginBottom: '16px'
}}
>
{uploading ? '업로드 중...' : '업로드'}
</Button>
</DialogActions>
</Dialog>
</Box>
뒤로가기
</button>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
📊 BOM 관리 시스템
</h1>
{jobNo && jobName && (
<h2 style={{
fontSize: '20px',
fontWeight: '600',
color: '#4299e1',
margin: '0 0 24px 0'
}}>
{jobNo} - {jobName}
</h2>
)}
</div>
{/* 파일 업로드 컴포넌트 */}
<BOMFileUpload
bomName={bomName}
setBomName={setBomName}
selectedFile={selectedFile}
setSelectedFile={setSelectedFile}
uploading={uploading}
handleUpload={handleUpload}
error={error}
/>
{/* BOM 목록 */}
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '32px 0 16px 0'
}}>
업로드된 BOM 목록
</h3>
{/* 파일 테이블 컴포넌트 */}
<BOMFileTable
files={files}
loading={loading}
groupFilesByBOM={groupFilesByBOM}
handleViewMaterials={handleViewMaterials}
openRevisionDialog={openRevisionDialog}
handleDelete={handleDelete}
/>
{/* 리비전 업로드 다이얼로그 */}
<RevisionUploadDialog
revisionDialog={revisionDialog}
setRevisionDialog={setRevisionDialog}
revisionFile={revisionFile}
setRevisionFile={setRevisionFile}
handleRevisionUpload={handleRevisionUpload}
uploading={uploading}
/>
{/* 구매 수량 계산 결과 모달 */}
{purchaseModal.open && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
maxWidth: '1000px',
maxHeight: '80vh',
overflow: 'auto',
margin: '20px'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px'
}}>
<h3 style={{
fontSize: '20px',
fontWeight: '700',
color: '#2d3748',
margin: 0
}}>
🧮 구매 수량 계산 결과
</h3>
<button
onClick={() => setPurchaseModal({ open: false, data: null, fileInfo: null })}
style={{
background: '#e2e8f0',
border: 'none',
borderRadius: '6px',
padding: '8px 12px',
cursor: 'pointer'
}}
>
닫기
</button>
</div>
<div style={{ marginBottom: '16px', color: '#4a5568' }}>
<div><strong>프로젝트:</strong> {purchaseModal.fileInfo?.job_no}</div>
<div><strong>BOM:</strong> {purchaseModal.fileInfo?.bom_name}</div>
<div><strong>리비전:</strong> {purchaseModal.fileInfo?.revision || 'Rev.0'}</div>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f7fafc' }}>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>카테고리</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사양</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사이즈</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>재질</th>
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>BOM 수량</th>
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>구매 수량</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>단위</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>비고</th>
</tr>
</thead>
<tbody>
{purchaseModal.data?.map((item, index) => (
<tr key={index} style={{ borderBottom: '1px solid #e2e8f0' }}>
<td style={{ padding: '12px', fontSize: '14px' }}>
<span style={{
background: getCategoryColor(item.category),
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '600'
}}>
{item.category}
</span>
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{item.specification}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{/* PIPE는 사양에 모든 정보가 포함되므로 사이즈 컬럼 비움 */}
{item.category !== 'PIPE' && (
<span style={{
background: '#e6fffa',
color: '#065f46',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500'
}}>
{item.size_spec || '-'}
</span>
)}
{item.category === 'PIPE' && (
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
사양에 포함
</span>
)}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{/* PIPE는 사양에 모든 정보가 포함되므로 재질 컬럼 비움 */}
{item.category !== 'PIPE' && (
<span style={{
background: '#fef7e0',
color: '#92400e',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500'
}}>
{item.material_spec || '-'}
</span>
)}
{item.category === 'PIPE' && (
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
사양에 포함
</span>
)}
</td>
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right' }}>
{item.category === 'PIPE' ?
`${Math.round(item.bom_quantity)}mm` :
item.bom_quantity
}
</td>
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right', fontWeight: '600' }}>
{item.category === 'PIPE' ?
`${item.pipes_count}본 (${Math.round(item.calculated_qty)}mm)` :
item.calculated_qty
}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{item.unit}
</td>
<td style={{ padding: '12px', fontSize: '12px', color: '#718096' }}>
{item.category === 'PIPE' && (
<div>
<div>절단수: {item.cutting_count}</div>
<div>절단손실: {item.cutting_loss}mm</div>
<div>활용률: {Math.round(item.utilization_rate)}%</div>
</div>
)}
{item.category !== 'PIPE' && item.safety_factor && (
<div>여유율: {Math.round((item.safety_factor - 1) * 100)}%</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{
marginTop: '24px',
padding: '16px',
background: '#f7fafc',
borderRadius: '8px',
fontSize: '14px',
color: '#4a5568'
}}>
<div style={{ fontWeight: '600', marginBottom: '8px' }}>📋 계산 규칙 (올바른 규칙):</div>
<div> <strong>PIPE:</strong> 6M 단위 올림, 절단당 2mm 손실</div>
<div> <strong>FITTING:</strong> BOM 수량 그대로</div>
<div> <strong>VALVE:</strong> BOM 수량 그대로</div>
<div> <strong>BOLT:</strong> 5% 여유율 4 배수 올림</div>
<div> <strong>GASKET:</strong> 5 배수 올림</div>
<div> <strong>INSTRUMENT:</strong> BOM 수량 그대로</div>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default BOMStatusPage;
export default BOMStatusPage;

View File

@@ -0,0 +1,264 @@
import React, { useState, useEffect } from 'react';
const DashboardPage = ({ user }) => {
const [stats, setStats] = useState({
totalProjects: 0,
activeProjects: 0,
completedProjects: 0,
totalMaterials: 0,
pendingQuotes: 0,
recentActivities: []
});
useEffect(() => {
// 실제로는 API에서 데이터를 가져올 예정
// 현재는 더미 데이터 사용
setStats({
totalProjects: 25,
activeProjects: 8,
completedProjects: 17,
totalMaterials: 1250,
pendingQuotes: 3,
recentActivities: [
{ id: 1, type: 'project', message: '냉동기 프로젝트 #2024-001 생성됨', time: '2시간 전' },
{ id: 2, type: 'bom', message: 'BOG 시스템 BOM 업데이트됨', time: '4시간 전' },
{ id: 3, type: 'quote', message: '다이아프람 펌프 견적서 승인됨', time: '6시간 전' },
{ id: 4, type: 'material', message: '스테인리스 파이프 재고 부족 알림', time: '1일 전' },
{ id: 5, type: 'shipment', message: '드라이어 시스템 출하 완료', time: '2일 전' }
]
});
}, []);
const getActivityIcon = (type) => {
const icons = {
project: '📋',
bom: '🔧',
quote: '💰',
material: '📦',
shipment: '🚚'
};
return icons[type] || '📌';
};
const StatCard = ({ title, value, icon, color = '#667eea' }) => (
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
border: '1px solid #e2e8f0',
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)';
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<div style={{
fontSize: '14px',
color: '#718096',
marginBottom: '8px',
fontWeight: '500'
}}>
{title}
</div>
<div style={{
fontSize: '32px',
fontWeight: '700',
color: '#2d3748'
}}>
{value}
</div>
</div>
<div style={{
fontSize: '32px',
opacity: 0.8
}}>
{icon}
</div>
</div>
</div>
);
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ marginBottom: '32px' }}>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
안녕하세요, {user?.name}! 👋
</h1>
<p style={{
color: '#718096',
fontSize: '16px',
margin: '0'
}}>
오늘도 TK-MP 시스템과 함께 효율적인 업무를 시작해보세요.
</p>
</div>
{/* 통계 카드들 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: '24px',
marginBottom: '32px'
}}>
<StatCard
title="전체 프로젝트"
value={stats.totalProjects}
icon="📋"
color="#667eea"
/>
<StatCard
title="진행중인 프로젝트"
value={stats.activeProjects}
icon="🚀"
color="#48bb78"
/>
<StatCard
title="완료된 프로젝트"
value={stats.completedProjects}
icon="✅"
color="#38b2ac"
/>
<StatCard
title="등록된 자재"
value={stats.totalMaterials}
icon="📦"
color="#ed8936"
/>
<StatCard
title="대기중인 견적"
value={stats.pendingQuotes}
icon="💰"
color="#9f7aea"
/>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
gap: '24px'
}}>
{/* 최근 활동 */}
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
border: '1px solid #e2e8f0'
}}>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 20px 0'
}}>
📈 최근 활동
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{stats.recentActivities.map(activity => (
<div key={activity.id} style={{
display: 'flex',
alignItems: 'flex-start',
gap: '12px',
padding: '12px',
borderRadius: '8px',
background: '#f7fafc',
border: '1px solid #e2e8f0'
}}>
<span style={{ fontSize: '16px' }}>
{getActivityIcon(activity.type)}
</span>
<div style={{ flex: 1 }}>
<div style={{
fontSize: '14px',
color: '#2d3748',
marginBottom: '4px'
}}>
{activity.message}
</div>
<div style={{
fontSize: '12px',
color: '#718096'
}}>
{activity.time}
</div>
</div>
</div>
))}
</div>
</div>
{/* 빠른 작업 */}
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
border: '1px solid #e2e8f0'
}}>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 20px 0'
}}>
빠른 작업
</h3>
<div style={{ display: 'grid', gap: '12px' }}>
{[
{ title: '새 프로젝트 등록', icon: '', color: '#667eea' },
{ title: 'BOM 업로드', icon: '📤', color: '#48bb78' },
{ title: '견적서 작성', icon: '📝', color: '#ed8936' },
{ title: '자재 검색', icon: '🔍', color: '#38b2ac' }
].map((action, index) => (
<button key={index} style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 16px',
background: 'transparent',
border: '1px solid #e2e8f0',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s ease',
fontSize: '14px',
color: '#4a5568'
}}
onMouseEnter={(e) => {
e.target.style.background = '#f7fafc';
e.target.style.borderColor = action.color;
}}
onMouseLeave={(e) => {
e.target.style.background = 'transparent';
e.target.style.borderColor = '#e2e8f0';
}}>
<span style={{ fontSize: '16px' }}>{action.icon}</span>
<span>{action.title}</span>
</button>
))}
</div>
</div>
</div>
</div>
</div>
);
};
export default DashboardPage;

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

@@ -1,15 +1,12 @@
import React, { useEffect, useState } from 'react';
import { Box, Typography, FormControl, InputLabel, Select, MenuItem, Button, CircularProgress, Alert } from '@mui/material';
import { fetchJobs } from '../api';
import { useNavigate } from 'react-router-dom';
const JobSelectionPage = () => {
const JobSelectionPage = ({ onJobSelect }) => {
const [jobs, setJobs] = useState([]);
const [selectedJobNo, setSelectedJobNo] = useState('');
const [selectedJobName, setSelectedJobName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
useEffect(() => {
async function loadJobs() {
@@ -39,47 +36,123 @@ const JobSelectionPage = () => {
};
const handleConfirm = () => {
if (selectedJobNo && selectedJobName) {
navigate(`/bom-status?job_no=${selectedJobNo}&job_name=${encodeURIComponent(selectedJobName)}`);
if (selectedJobNo && selectedJobName && onJobSelect) {
onJobSelect(selectedJobNo, selectedJobName);
}
};
return (
<Box sx={{ maxWidth: 500, mx: 'auto', mt: 8 }}>
<Typography variant="h4" gutterBottom>프로젝트 선택</Typography>
{loading && <CircularProgress sx={{ mt: 4 }} />}
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<FormControl size="small" fullWidth sx={{ mt: 3 }}>
<InputLabel>프로젝트</InputLabel>
<Select
value={selectedJobNo}
label="프로젝트"
onChange={handleSelect}
displayEmpty
>
<MenuItem value="">선택</MenuItem>
{jobs.map(job => (
<MenuItem key={job.job_no} value={job.job_no}>
{job.job_no} ({job.job_name})
</MenuItem>
))}
</Select>
</FormControl>
{selectedJobNo && (
<Alert severity="info" sx={{ mt: 3 }}>
선택된 프로젝트: <b>{selectedJobNo} ({selectedJobName})</b>
</Alert>
)}
<Button
variant="contained"
sx={{ mt: 4, minWidth: 120 }}
disabled={!selectedJobNo}
onClick={handleConfirm}
>
확인
</Button>
</Box>
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '600px', margin: '0 auto' }}>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
📋 프로젝트 선택
</h1>
<p style={{
color: '#718096',
fontSize: '16px',
margin: '0 0 32px 0'
}}>
BOM 관리할 프로젝트를 선택하세요.
</p>
{loading && (
<div style={{ textAlign: 'center', padding: '40px' }}>
로딩 ...
</div>
)}
{error && (
<div style={{
background: '#fed7d7',
border: '1px solid #fc8181',
borderRadius: '8px',
padding: '12px 16px',
marginBottom: '20px',
color: '#c53030'
}}>
{error}
</div>
)}
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
padding: '24px'
}}>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '8px'
}}>
프로젝트 선택
</label>
<select
value={selectedJobNo}
onChange={handleSelect}
style={{
width: '100%',
padding: '12px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
fontSize: '14px',
background: 'white',
marginBottom: '16px'
}}
>
<option value="">프로젝트를 선택하세요</option>
{jobs.map(job => (
<option key={job.job_no} value={job.job_no}>
{job.job_no} - {job.job_name}
</option>
))}
</select>
{selectedJobNo && selectedJobName && (
<div style={{
background: '#c6f6d5',
border: '1px solid #68d391',
borderRadius: '8px',
padding: '12px 16px',
marginBottom: '20px',
color: '#2f855a'
}}>
선택된 프로젝트: <strong>{selectedJobNo} - {selectedJobName}</strong>
</div>
)}
<button
onClick={handleConfirm}
disabled={!selectedJobNo}
style={{
width: '100%',
padding: '12px 24px',
background: selectedJobNo ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : '#e2e8f0',
color: selectedJobNo ? 'white' : '#a0aec0',
border: 'none',
borderRadius: '8px',
cursor: selectedJobNo ? 'pointer' : 'not-allowed',
fontSize: '16px',
fontWeight: '600'
}}
>
확인
</button>
</div>
</div>
</div>
);
};
export default JobSelectionPage;
export default JobSelectionPage;

View File

@@ -0,0 +1,219 @@
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.login-card {
background: white;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
width: 100%;
max-width: 400px;
animation: slideUp 0.6s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-header h1 {
color: #2d3748;
font-size: 28px;
font-weight: 700;
margin: 0 0 8px 0;
}
.login-header p {
color: #718096;
font-size: 14px;
margin: 0;
}
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
color: #2d3748;
font-weight: 600;
font-size: 14px;
}
.form-group input {
padding: 12px 16px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 16px;
transition: all 0.2s ease;
background: #f7fafc;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
background: white;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-group input:disabled {
background: #f1f5f9;
color: #94a3b8;
cursor: not-allowed;
}
.error-message {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #fed7d7;
border: 1px solid #feb2b2;
border-radius: 8px;
color: #c53030;
font-size: 14px;
animation: shake 0.5s ease-in-out;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.error-icon {
font-size: 16px;
}
.login-button {
padding: 14px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 8px;
}
.login-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
}
.login-button:active:not(:disabled) {
transform: translateY(0);
}
.login-button:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top: 2px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.login-footer {
margin-top: 32px;
text-align: center;
}
.login-footer p {
color: #718096;
font-size: 14px;
margin: 0 0 16px 0;
}
.system-info small {
color: #a0aec0;
font-size: 12px;
}
/* 반응형 디자인 */
@media (max-width: 480px) {
.login-container {
padding: 16px;
}
.login-card {
padding: 24px;
}
.login-header h1 {
font-size: 24px;
}
}
/* 다크모드 지원 */
@media (prefers-color-scheme: dark) {
.login-card {
background: #1a202c;
color: white;
}
.login-header h1 {
color: white;
}
.login-header p {
color: #a0aec0;
}
.form-group label {
color: #e2e8f0;
}
.form-group input {
background: #2d3748;
border-color: #4a5568;
color: white;
}
.form-group input:focus {
background: #2d3748;
border-color: #667eea;
}
}

View File

@@ -0,0 +1,116 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import './LoginPage.css';
const LoginPage = () => {
const [formData, setFormData] = useState({
username: '',
password: ''
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { login } = useAuth();
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// 입력 시 에러 메시지 초기화
if (error) setError('');
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.username || !formData.password) {
setError('사용자명과 비밀번호를 입력해주세요.');
return;
}
setIsLoading(true);
setError('');
try {
await login(formData.username, formData.password);
} catch (err) {
setError(err.message || '로그인에 실패했습니다.');
} finally {
setIsLoading(false);
}
};
return (
<div className="login-container">
<div className="login-card">
<div className="login-header">
<h1>🚀 TK-MP System</h1>
<p>통합 프로젝트 관리 시스템</p>
</div>
<form onSubmit={handleSubmit} className="login-form">
<div className="form-group">
<label htmlFor="username">사용자명</label>
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleChange}
placeholder="사용자명을 입력하세요"
disabled={isLoading}
autoComplete="username"
/>
</div>
<div className="form-group">
<label htmlFor="password">비밀번호</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="비밀번호를 입력하세요"
disabled={isLoading}
autoComplete="current-password"
/>
</div>
{error && (
<div className="error-message">
<span className="error-icon"></span>
{error}
</div>
)}
<button
type="submit"
className="login-button"
disabled={isLoading}
>
{isLoading ? (
<>
<span className="loading-spinner"></span>
로그인 ...
</>
) : (
'로그인'
)}
</button>
</form>
<div className="login-footer">
<p>계정이 없으신가요? 관리자에게 문의하세요.</p>
<div className="system-info">
<small>TK-MP Project Management System v2.0</small>
</div>
</div>
</div>
</div>
);
};
export default LoginPage;

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;

View File

@@ -0,0 +1,486 @@
import React, { useState, useEffect } from 'react';
import MaterialList from '../components/MaterialList';
import { fetchMaterials } from '../api';
const MaterialsManagementPage = ({ user }) => {
const [materials, setMaterials] = useState([]);
const [filteredMaterials, setFilteredMaterials] = useState([]);
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState({
project: '',
category: '',
status: '',
search: ''
});
const [stats, setStats] = useState({
totalMaterials: 0,
categorizedMaterials: 0,
uncategorizedMaterials: 0,
categories: {}
});
useEffect(() => {
loadProjects();
loadAllMaterials();
}, []);
useEffect(() => {
applyFilters();
}, [materials, filters]);
const loadProjects = async () => {
try {
const response = await fetch('/api/jobs/');
const data = await response.json();
if (data.success) {
setProjects(data.jobs);
}
} catch (error) {
console.error('프로젝트 로딩 실패:', error);
}
};
const loadAllMaterials = async () => {
setLoading(true);
try {
// 기존 API 함수 사용 - 모든 자재 데이터 로딩
const response = await fetchMaterials({ limit: 10000 }); // 충분히 큰 limit
const materialsData = response.data?.materials || [];
setMaterials(materialsData);
calculateStats(materialsData);
} catch (error) {
console.error('자재 데이터 로딩 실패:', error);
} finally {
setLoading(false);
}
};
const calculateStats = (materialsData) => {
const totalMaterials = materialsData.length;
const categorizedMaterials = materialsData.filter(m => m.classified_category && m.classified_category !== 'Unknown').length;
const uncategorizedMaterials = totalMaterials - categorizedMaterials;
// 카테고리별 통계
const categories = {};
materialsData.forEach(material => {
const category = material.classified_category || 'Unknown';
categories[category] = (categories[category] || 0) + 1;
});
setStats({
totalMaterials,
categorizedMaterials,
uncategorizedMaterials,
categories
});
};
const applyFilters = () => {
let filtered = [...materials];
// 프로젝트 필터
if (filters.project) {
filtered = filtered.filter(m => m.job_no === filters.project);
}
// 카테고리 필터
if (filters.category) {
filtered = filtered.filter(m => m.classified_category === filters.category);
}
// 상태 필터
if (filters.status) {
if (filters.status === 'categorized') {
filtered = filtered.filter(m => m.classified_category && m.classified_category !== 'Unknown');
} else if (filters.status === 'uncategorized') {
filtered = filtered.filter(m => !m.classified_category || m.classified_category === 'Unknown');
}
}
// 검색 필터
if (filters.search) {
const searchTerm = filters.search.toLowerCase();
filtered = filtered.filter(m =>
(m.original_description && m.original_description.toLowerCase().includes(searchTerm)) ||
(m.size_spec && m.size_spec.toLowerCase().includes(searchTerm)) ||
(m.classified_category && m.classified_category.toLowerCase().includes(searchTerm))
);
}
setFilteredMaterials(filtered);
};
const handleFilterChange = (filterType, value) => {
setFilters(prev => ({
...prev,
[filterType]: value
}));
};
const clearFilters = () => {
setFilters({
project: '',
category: '',
status: '',
search: ''
});
};
const StatCard = ({ title, value, icon, color = '#667eea', subtitle }) => (
<div style={{
background: 'white',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
border: '1px solid #e2e8f0'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '10px',
background: color + '20',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px'
}}>
{icon}
</div>
<div>
<div style={{
fontSize: '24px',
fontWeight: '700',
color: '#2d3748'
}}>
{value.toLocaleString()}
</div>
<div style={{
fontSize: '14px',
color: '#718096'
}}>
{title}
</div>
</div>
</div>
{subtitle && (
<div style={{
fontSize: '12px',
color: '#718096',
marginTop: '4px'
}}>
{subtitle}
</div>
)}
</div>
);
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ marginBottom: '32px' }}>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
📦 자재 관리
</h1>
<p style={{
color: '#718096',
fontSize: '16px',
margin: '0'
}}>
전체 프로젝트의 자재 정보를 통합 관리하고 분석하세요.
</p>
</div>
{/* 통계 카드들 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: '20px',
marginBottom: '32px'
}}>
<StatCard
title="전체 자재"
value={stats.totalMaterials}
icon="📦"
color="#667eea"
subtitle={`${projects.length}개 프로젝트`}
/>
<StatCard
title="분류 완료"
value={stats.categorizedMaterials}
icon="✅"
color="#48bb78"
subtitle={`${Math.round((stats.categorizedMaterials / stats.totalMaterials) * 100) || 0}% 완료`}
/>
<StatCard
title="미분류"
value={stats.uncategorizedMaterials}
icon="⚠️"
color="#ed8936"
subtitle="분류 작업 필요"
/>
<StatCard
title="카테고리"
value={Object.keys(stats.categories).length}
icon="🏷️"
color="#9f7aea"
subtitle="자재 분류 유형"
/>
</div>
{/* 필터 섹션 */}
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
padding: '24px',
marginBottom: '24px'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px'
}}>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0'
}}>
🔍 필터 검색
</h3>
<button
onClick={clearFilters}
style={{
padding: '8px 16px',
background: '#e2e8f0',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
color: '#4a5568'
}}
>
필터 초기화
</button>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '16px'
}}>
{/* 프로젝트 필터 */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '6px'
}}>
프로젝트
</label>
<select
value={filters.project}
onChange={(e) => handleFilterChange('project', e.target.value)}
style={{
width: '100%',
padding: '10px',
border: '1px solid #e2e8f0',
borderRadius: '6px',
fontSize: '14px'
}}
>
<option value="">전체 프로젝트</option>
{projects.map(project => (
<option key={project.job_no} value={project.job_no}>
{project.job_no} - {project.job_name}
</option>
))}
</select>
</div>
{/* 카테고리 필터 */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '6px'
}}>
카테고리
</label>
<select
value={filters.category}
onChange={(e) => handleFilterChange('category', e.target.value)}
style={{
width: '100%',
padding: '10px',
border: '1px solid #e2e8f0',
borderRadius: '6px',
fontSize: '14px'
}}
>
<option value="">전체 카테고리</option>
{Object.keys(stats.categories).map(category => (
<option key={category} value={category}>
{category} ({stats.categories[category]})
</option>
))}
</select>
</div>
{/* 상태 필터 */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '6px'
}}>
분류 상태
</label>
<select
value={filters.status}
onChange={(e) => handleFilterChange('status', e.target.value)}
style={{
width: '100%',
padding: '10px',
border: '1px solid #e2e8f0',
borderRadius: '6px',
fontSize: '14px'
}}
>
<option value="">전체</option>
<option value="categorized">분류 완료</option>
<option value="uncategorized">미분류</option>
</select>
</div>
{/* 검색 */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '6px'
}}>
검색
</label>
<input
type="text"
placeholder="자재명, 코드, 카테고리 검색..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
style={{
width: '100%',
padding: '10px',
border: '1px solid #e2e8f0',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
</div>
{/* 필터 결과 요약 */}
<div style={{
marginTop: '16px',
padding: '12px',
background: '#f7fafc',
borderRadius: '6px',
fontSize: '14px',
color: '#4a5568'
}}>
<strong>{filteredMaterials.length.toLocaleString()}</strong>개의 자재가 검색되었습니다.
{filters.project && ` (프로젝트: ${filters.project})`}
{filters.category && ` (카테고리: ${filters.category})`}
{filters.status && ` (상태: ${filters.status === 'categorized' ? '분류완료' : '미분류'})`}
{filters.search && ` (검색: "${filters.search}")`}
</div>
</div>
{/* 자재 목록 */}
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
overflow: 'hidden'
}}>
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e2e8f0',
background: '#f7fafc',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<h3 style={{
margin: '0',
fontSize: '16px',
fontWeight: '600',
color: '#2d3748'
}}>
자재 목록 ({filteredMaterials.length.toLocaleString()})
</h3>
<div style={{ display: 'flex', gap: '8px' }}>
<button
style={{
padding: '8px 16px',
background: '#667eea',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px'
}}
>
📊 분석 리포트
</button>
<button
style={{
padding: '8px 16px',
background: '#48bb78',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px'
}}
>
📤 Excel 내보내기
</button>
</div>
</div>
<MaterialList
selectedProject={null} // 전체 자재 보기
showProjectInfo={true}
enableSelection={true}
key="all-materials" // 전체 자재 모드
/>
</div>
</div>
</div>
);
};
export default MaterialsManagementPage;

File diff suppressed because it is too large Load Diff

View File

@@ -1,71 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Box, Typography, FormControl, InputLabel, Select, MenuItem, CircularProgress, Alert, Button } from '@mui/material';
import { fetchJobs } from '../api';
import { useNavigate } from 'react-router-dom';
const ProjectSelectionPage = () => {
const [jobs, setJobs] = useState([]);
const [selectedJob, setSelectedJob] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
useEffect(() => {
async function loadJobs() {
setLoading(true);
setError('');
try {
const res = await fetchJobs({});
if (res.data && Array.isArray(res.data.jobs)) {
setJobs(res.data.jobs);
} else {
setJobs([]);
}
} catch (e) {
setError('프로젝트 목록을 불러오지 못했습니다.');
} finally {
setLoading(false);
}
}
loadJobs();
}, []);
return (
<Box sx={{ maxWidth: 500, mx: 'auto', mt: 8 }}>
<Typography variant="h4" gutterBottom>프로젝트(Job No) 선택</Typography>
{loading && <CircularProgress sx={{ mt: 4 }} />}
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<FormControl size="small" fullWidth sx={{ mt: 3 }}>
<InputLabel>Job No</InputLabel>
<Select
value={selectedJob}
label="Job No"
onChange={e => setSelectedJob(e.target.value)}
displayEmpty
>
<MenuItem value="">선택</MenuItem>
{jobs.map(job => (
<MenuItem key={job.job_no} value={job.job_no}>
{job.job_no} ({job.job_name})
</MenuItem>
))}
</Select>
</FormControl>
{selectedJob && (
<Alert severity="info" sx={{ mt: 3 }}>
선택된 Job No: <b>{selectedJob}</b>
</Alert>
)}
<Button
variant="contained"
sx={{ mt: 4, minWidth: 120 }}
disabled={!selectedJob}
onClick={() => navigate(`/bom-status?job_no=${selectedJob}`)}
>
확인
</Button>
</Box>
);
};
export default ProjectSelectionPage;

View File

@@ -0,0 +1,388 @@
import React, { useState, useEffect } from 'react';
const ProjectsPage = ({ user }) => {
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreateForm, setShowCreateForm] = useState(false);
useEffect(() => {
// 실제로는 API에서 프로젝트 데이터를 가져올 예정
// 현재는 더미 데이터 사용
setTimeout(() => {
setProjects([
{
id: 1,
name: '냉동기 시스템 개발',
type: '냉동기',
status: '진행중',
startDate: '2024-01-15',
endDate: '2024-06-30',
deliveryMethod: 'FOB',
progress: 65,
manager: '김철수'
},
{
id: 2,
name: 'BOG 처리 시스템',
type: 'BOG',
status: '계획',
startDate: '2024-02-01',
endDate: '2024-08-15',
deliveryMethod: 'CIF',
progress: 15,
manager: '이영희'
},
{
id: 3,
name: '다이아프람 펌프 제작',
type: '다이아프람',
status: '완료',
startDate: '2023-10-01',
endDate: '2024-01-31',
deliveryMethod: 'FOB',
progress: 100,
manager: '박민수'
}
]);
setLoading(false);
}, 1000);
}, []);
const getStatusColor = (status) => {
const colors = {
'계획': '#ed8936',
'진행중': '#48bb78',
'완료': '#38b2ac',
'보류': '#e53e3e'
};
return colors[status] || '#718096';
};
const getProgressColor = (progress) => {
if (progress >= 80) return '#48bb78';
if (progress >= 50) return '#ed8936';
return '#e53e3e';
};
if (loading) {
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px',
fontSize: '16px',
color: '#718096'
}}>
프로젝트 목록을 불러오는 ...
</div>
);
}
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '32px'
}}>
<div>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
📋 프로젝트 관리
</h1>
<p style={{
color: '#718096',
fontSize: '16px',
margin: '0'
}}>
전체 프로젝트를 관리하고 진행 상황을 확인하세요.
</p>
</div>
<button
onClick={() => setShowCreateForm(true)}
style={{
padding: '12px 24px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
gap: '8px',
transition: 'transform 0.2s ease'
}}
onMouseEnter={(e) => e.target.style.transform = 'translateY(-1px)'}
onMouseLeave={(e) => e.target.style.transform = 'translateY(0)'}
>
<span></span>
프로젝트
</button>
</div>
{/* 프로젝트 통계 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '16px',
marginBottom: '32px'
}}>
{[
{ label: '전체', count: projects.length, color: '#667eea' },
{ label: '진행중', count: projects.filter(p => p.status === '진행중').length, color: '#48bb78' },
{ label: '완료', count: projects.filter(p => p.status === '완료').length, color: '#38b2ac' },
{ label: '계획', count: projects.filter(p => p.status === '계획').length, color: '#ed8936' }
].map((stat, index) => (
<div key={index} style={{
background: 'white',
padding: '20px',
borderRadius: '12px',
border: '1px solid #e2e8f0',
textAlign: 'center'
}}>
<div style={{
fontSize: '24px',
fontWeight: '700',
color: stat.color,
marginBottom: '4px'
}}>
{stat.count}
</div>
<div style={{
fontSize: '14px',
color: '#718096'
}}>
{stat.label}
</div>
</div>
))}
</div>
{/* 프로젝트 목록 */}
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
overflow: 'hidden'
}}>
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e2e8f0',
background: '#f7fafc'
}}>
<h3 style={{
margin: '0',
fontSize: '16px',
fontWeight: '600',
color: '#2d3748'
}}>
프로젝트 목록 ({projects.length})
</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{
width: '100%',
borderCollapse: 'collapse'
}}>
<thead>
<tr style={{ background: '#f7fafc' }}>
{['프로젝트명', '유형', '상태', '수주일', '납기일', '납품방법', '진행률', '담당자'].map(header => (
<th key={header} style={{
padding: '12px 16px',
textAlign: 'left',
fontSize: '12px',
fontWeight: '600',
color: '#4a5568',
borderBottom: '1px solid #e2e8f0'
}}>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{projects.map(project => (
<tr key={project.id} style={{
borderBottom: '1px solid #e2e8f0',
transition: 'background 0.2s ease'
}}
onMouseEnter={(e) => e.currentTarget.style.background = '#f7fafc'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}>
<td style={{ padding: '16px', fontWeight: '600', color: '#2d3748' }}>
{project.name}
</td>
<td style={{ padding: '16px' }}>
<span style={{
padding: '4px 8px',
background: '#edf2f7',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500',
color: '#4a5568'
}}>
{project.type}
</span>
</td>
<td style={{ padding: '16px' }}>
<span style={{
padding: '4px 8px',
background: getStatusColor(project.status) + '20',
color: getStatusColor(project.status),
borderRadius: '4px',
fontSize: '12px',
fontWeight: '600'
}}>
{project.status}
</span>
</td>
<td style={{ padding: '16px', color: '#4a5568' }}>
{project.startDate}
</td>
<td style={{ padding: '16px', color: '#4a5568' }}>
{project.endDate}
</td>
<td style={{ padding: '16px', color: '#4a5568' }}>
{project.deliveryMethod}
</td>
<td style={{ padding: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{
flex: 1,
height: '6px',
background: '#e2e8f0',
borderRadius: '3px',
overflow: 'hidden'
}}>
<div style={{
width: `${project.progress}%`,
height: '100%',
background: getProgressColor(project.progress),
transition: 'width 0.3s ease'
}} />
</div>
<span style={{
fontSize: '12px',
fontWeight: '600',
color: getProgressColor(project.progress),
minWidth: '35px'
}}>
{project.progress}%
</span>
</div>
</td>
<td style={{ padding: '16px', color: '#4a5568' }}>
{project.manager}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 프로젝트가 없을 때 */}
{projects.length === 0 && (
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
padding: '60px 40px',
textAlign: 'center'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📋</div>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
등록된 프로젝트가 없습니다
</h3>
<p style={{
color: '#718096',
margin: '0 0 24px 0'
}}>
번째 프로젝트를 등록해보세요.
</p>
<button
onClick={() => setShowCreateForm(true)}
style={{
padding: '12px 24px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
프로젝트 등록
</button>
</div>
)}
</div>
{/* 프로젝트 생성 폼 모달 (향후 구현) */}
{showCreateForm && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
background: 'white',
borderRadius: '12px',
padding: '32px',
maxWidth: '500px',
width: '90%',
maxHeight: '90vh',
overflow: 'auto'
}}>
<h3 style={{ margin: '0 0 16px 0' }}> 프로젝트 등록</h3>
<p style={{ color: '#718096', margin: '0 0 24px 0' }}>
기능은 구현될 예정입니다.
</p>
<button
onClick={() => setShowCreateForm(false)}
style={{
padding: '8px 16px',
background: '#e2e8f0',
border: 'none',
borderRadius: '6px',
cursor: 'pointer'
}}
>
닫기
</button>
</div>
</div>
)}
</div>
);
};
export default ProjectsPage;

View File

@@ -0,0 +1,742 @@
import React, { useState, useEffect } from 'react';
import { api } from '../api';
const SimpleMaterialsPage = ({ fileId, jobNo: propJobNo, bomName: propBomName, revision: propRevision, filename: propFilename, onNavigate }) => {
const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [fileName, setFileName] = useState('');
const [jobNo, setJobNo] = useState('');
const [bomName, setBomName] = useState('');
const [currentRevision, setCurrentRevision] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [filterCategory, setFilterCategory] = useState('all');
const [filterConfidence, setFilterConfidence] = useState('all');
const [showPurchaseCalculation, setShowPurchaseCalculation] = useState(false);
const [purchaseData, setPurchaseData] = useState(null);
const [calculatingPurchase, setCalculatingPurchase] = useState(false);
useEffect(() => {
// Props로 받은 값들을 초기화
if (propJobNo) setJobNo(propJobNo);
if (propBomName) setBomName(propBomName);
if (propRevision) setCurrentRevision(propRevision);
if (propFilename) setFileName(propFilename);
if (fileId) {
loadMaterials(fileId);
} else {
setLoading(false);
setError('파일 ID가 지정되지 않았습니다.');
}
}, [fileId, propJobNo, propBomName, propRevision, propFilename]);
const loadMaterials = async (id) => {
try {
setLoading(true);
const response = await api.get('/files/materials', {
params: { file_id: parseInt(id), limit: 10000 }
});
if (response.data && response.data.materials) {
setMaterials(response.data.materials);
// 파일 정보 설정
if (response.data.materials.length > 0) {
const firstMaterial = response.data.materials[0];
setFileName(firstMaterial.filename || '');
setJobNo(firstMaterial.project_code || '');
setBomName(firstMaterial.filename || '');
setCurrentRevision('Rev.0'); // API에서 revision 정보가 없으므로 기본값
}
} else {
setMaterials([]);
}
} catch (err) {
console.error('자재 목록 로드 실패:', err);
setError('자재 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
// 구매 수량 계산 함수 (기존 BOM 규칙 적용)
const calculatePurchaseQuantities = async () => {
if (!jobNo || !currentRevision) {
alert('프로젝트 정보가 없습니다.');
return;
}
setCalculatingPurchase(true);
try {
const response = await api.get(`/purchase/calculate`, {
params: {
job_no: jobNo,
revision: currentRevision,
file_id: fileId
}
});
if (response.data && response.data.success) {
setPurchaseData(response.data.purchase_items);
setShowPurchaseCalculation(true);
} else {
throw new Error('구매 수량 계산 실패');
}
} catch (error) {
console.error('구매 수량 계산 오류:', error);
alert('구매 수량 계산에 실패했습니다.');
} finally {
setCalculatingPurchase(false);
}
};
// 필터링된 자재 목록 (기존 BOM 규칙 적용)
const filteredMaterials = materials.filter(material => {
const matchesSearch = !searchTerm ||
material.original_description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.size_spec?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.material_grade?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = filterCategory === 'all' ||
material.classified_category === filterCategory;
// 신뢰도 필터링 (기존 BOM 규칙)
const matchesConfidence = filterConfidence === 'all' ||
(filterConfidence === 'high' && material.classification_confidence >= 0.9) ||
(filterConfidence === 'medium' && material.classification_confidence >= 0.7 && material.classification_confidence < 0.9) ||
(filterConfidence === 'low' && material.classification_confidence < 0.7);
return matchesSearch && matchesCategory && matchesConfidence;
});
// 카테고리별 통계
const categoryStats = materials.reduce((acc, material) => {
const category = material.classified_category || 'unknown';
acc[category] = (acc[category] || 0) + 1;
return acc;
}, {});
const categories = Object.keys(categoryStats).sort();
// 카테고리별 색상 함수
const getCategoryColor = (category) => {
const colors = {
'pipe': '#4299e1',
'fitting': '#48bb78',
'valve': '#ed8936',
'flange': '#9f7aea',
'bolt': '#38b2ac',
'gasket': '#f56565',
'instrument': '#d69e2e',
'material': '#718096',
'integrated': '#319795',
'unknown': '#a0aec0'
};
return colors[category?.toLowerCase()] || colors.unknown;
};
// 신뢰도 배지 함수 (기존 BOM 규칙 적용)
const getConfidenceBadge = (confidence) => {
if (!confidence) return '-';
const conf = parseFloat(confidence);
let color, text;
if (conf >= 0.9) {
color = '#48bb78'; // 녹색
text = '높음';
} else if (conf >= 0.7) {
color = '#ed8936'; // 주황색
text = '보통';
} else {
color = '#f56565'; // 빨간색
text = '낮음';
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{
background: color,
color: 'white',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '11px',
fontWeight: '600'
}}>
{text}
</span>
<span style={{ fontSize: '11px', color: '#718096' }}>
{Math.round(conf * 100)}%
</span>
</div>
);
};
// 상세정보 표시 함수 (기존 BOM 규칙 적용)
const getDetailInfo = (material) => {
const details = [];
// PIPE 상세정보
if (material.pipe_details) {
const pipe = material.pipe_details;
if (pipe.schedule) details.push(`SCH ${pipe.schedule}`);
if (pipe.manufacturing_method) details.push(pipe.manufacturing_method);
if (pipe.end_preparation) details.push(pipe.end_preparation);
}
// FITTING 상세정보
if (material.fitting_details) {
const fitting = material.fitting_details;
if (fitting.fitting_type) details.push(fitting.fitting_type);
if (fitting.connection_method && fitting.connection_method !== 'UNKNOWN') {
details.push(fitting.connection_method);
}
if (fitting.pressure_rating && fitting.pressure_rating !== 'UNKNOWN') {
details.push(fitting.pressure_rating);
}
}
// VALVE 상세정보
if (material.valve_details) {
const valve = material.valve_details;
if (valve.valve_type) details.push(valve.valve_type);
if (valve.connection_type) details.push(valve.connection_type);
if (valve.pressure_rating) details.push(valve.pressure_rating);
}
// BOLT 상세정보
if (material.bolt_details) {
const bolt = material.bolt_details;
if (bolt.fastener_type) details.push(bolt.fastener_type);
if (bolt.thread_specification) details.push(bolt.thread_specification);
if (bolt.length_mm) details.push(`L${bolt.length_mm}mm`);
}
// FLANGE 상세정보
if (material.flange_details) {
const flange = material.flange_details;
if (flange.flange_type) details.push(flange.flange_type);
if (flange.pressure_rating) details.push(flange.pressure_rating);
if (flange.facing_type) details.push(flange.facing_type);
}
return details.length > 0 ? (
<div style={{ fontSize: '11px', color: '#4a5568' }}>
{details.slice(0, 2).map((detail, idx) => (
<div key={idx} style={{
background: '#f7fafc',
padding: '2px 4px',
borderRadius: '3px',
marginBottom: '2px',
display: 'inline-block',
marginRight: '4px'
}}>
{detail}
</div>
))}
{details.length > 2 && (
<span style={{ color: '#718096' }}>+{details.length - 2}</span>
)}
</div>
) : '-';
};
if (loading) {
return (
<div style={{
padding: '32px',
textAlign: 'center',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ padding: '40px' }}>
로딩 ...
</div>
</div>
);
}
if (error) {
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{
background: '#fed7d7',
border: '1px solid #fc8181',
borderRadius: '8px',
padding: '12px 16px',
color: '#c53030'
}}>
{error}
</div>
</div>
);
}
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ marginBottom: '24px' }}>
<button
onClick={() => onNavigate && onNavigate('bom-status', { job_no: jobNo, job_name: bomName })}
style={{
padding: '8px 16px',
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: '6px',
cursor: 'pointer',
marginBottom: '16px'
}}
>
뒤로가기
</button>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
📦 자재 목록
</h1>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-end',
margin: '0 0 24px 0'
}}>
<div style={{
fontSize: '16px',
color: '#718096'
}}>
<div><strong>프로젝트:</strong> {jobNo}</div>
<div><strong>BOM:</strong> {bomName}</div>
<div><strong>리비전:</strong> {currentRevision}</div>
<div><strong> 자재 :</strong> {materials.length}</div>
</div>
<button
onClick={calculatePurchaseQuantities}
disabled={calculatingPurchase}
style={{
background: calculatingPurchase ? '#a0aec0' : '#48bb78',
color: 'white',
padding: '12px 20px',
borderRadius: '8px',
fontSize: '14px',
fontWeight: '600',
border: 'none',
cursor: calculatingPurchase ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
{calculatingPurchase ? '계산중...' : '🧮 구매수량 계산'}
</button>
</div>
</div>
{/* 검색 및 필터 */}
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
padding: '24px',
marginBottom: '24px'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 200px 200px',
gap: '16px',
alignItems: 'end'
}}>
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '8px'
}}>
자재 검색
</label>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="자재명, 규격, 설명으로 검색..."
style={{
width: '100%',
padding: '12px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '8px'
}}>
카테고리 필터
</label>
<select
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value)}
style={{
width: '100%',
padding: '12px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
fontSize: '14px',
background: 'white'
}}
>
<option value="all">전체 ({materials.length})</option>
{categories.map(category => (
<option key={category} value={category}>
{category} ({categoryStats[category]})
</option>
))}
</select>
</div>
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '8px'
}}>
신뢰도 필터
</label>
<select
value={filterConfidence}
onChange={(e) => setFilterConfidence(e.target.value)}
style={{
width: '100%',
padding: '12px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
fontSize: '14px',
background: 'white'
}}
>
<option value="all">전체</option>
<option value="high">높음 (90%+)</option>
<option value="medium">보통 (70-89%)</option>
<option value="low">낮음 (70% 미만)</option>
</select>
</div>
</div>
</div>
{/* 통계 카드 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '16px',
marginBottom: '24px'
}}>
{categories.slice(0, 6).map(category => (
<div key={category} style={{
background: 'white',
borderRadius: '8px',
border: '1px solid #e2e8f0',
padding: '16px',
textAlign: 'center'
}}>
<div style={{ fontSize: '24px', fontWeight: '700', color: '#4299e1' }}>
{categoryStats[category]}
</div>
<div style={{ fontSize: '14px', color: '#718096', marginTop: '4px' }}>
{category}
</div>
</div>
))}
</div>
{/* 자재 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
overflow: 'hidden'
}}>
<div style={{ padding: '20px', borderBottom: '1px solid #e2e8f0' }}>
<h3 style={{ margin: '0', fontSize: '18px', fontWeight: '600' }}>
자재 목록 ({filteredMaterials.length})
</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f7fafc' }}>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>No.</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>자재명</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>규격</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>수량</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>단위</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>카테고리</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>재질</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>신뢰도</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>상세정보</th>
</tr>
</thead>
<tbody>
{filteredMaterials.map((material, index) => (
<tr key={material.id || index} style={{
borderBottom: '1px solid #e2e8f0'
}}>
<td style={{ padding: '12px', fontSize: '14px' }}>
{material.line_number || index + 1}
</td>
<td style={{ padding: '12px', fontSize: '14px', fontWeight: '500' }}>
{material.original_description || '-'}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{material.size_spec || material.main_nom || '-'}
</td>
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right' }}>
{material.quantity || '-'}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{material.unit || '-'}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
<span style={{
background: getCategoryColor(material.classified_category),
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '600'
}}>
{material.classified_category || 'unknown'}
</span>
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{material.material_grade || '-'}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{getConfidenceBadge(material.classification_confidence)}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{getDetailInfo(material)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredMaterials.length === 0 && (
<div style={{
padding: '40px',
textAlign: 'center',
color: '#718096'
}}>
검색 조건에 맞는 자재가 없습니다.
</div>
)}
</div>
{/* 구매 수량 계산 결과 모달 */}
{showPurchaseCalculation && purchaseData && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
maxWidth: '1000px',
maxHeight: '80vh',
overflow: 'auto',
margin: '20px'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px'
}}>
<h3 style={{
fontSize: '20px',
fontWeight: '700',
color: '#2d3748',
margin: 0
}}>
🧮 구매 수량 계산 결과
</h3>
<button
onClick={() => setShowPurchaseCalculation(false)}
style={{
background: '#e2e8f0',
border: 'none',
borderRadius: '6px',
padding: '8px 12px',
cursor: 'pointer'
}}
>
닫기
</button>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f7fafc' }}>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>카테고리</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사양</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사이즈</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>재질</th>
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>BOM 수량</th>
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>구매 수량</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>단위</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>비고</th>
</tr>
</thead>
<tbody>
{purchaseData.map((item, index) => (
<tr key={index} style={{ borderBottom: '1px solid #e2e8f0' }}>
<td style={{ padding: '12px', fontSize: '14px' }}>
<span style={{
background: getCategoryColor(item.category),
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '600'
}}>
{item.category}
</span>
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{item.specification}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{/* PIPE는 사양에 모든 정보가 포함되므로 사이즈 컬럼 비움 */}
{item.category !== 'PIPE' && (
<span style={{
background: '#e6fffa',
color: '#065f46',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500'
}}>
{item.size_spec || '-'}
</span>
)}
{item.category === 'PIPE' && (
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
사양에 포함
</span>
)}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{/* PIPE는 사양에 모든 정보가 포함되므로 재질 컬럼 비움 */}
{item.category !== 'PIPE' && (
<span style={{
background: '#fef7e0',
color: '#92400e',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500'
}}>
{item.material_spec || '-'}
</span>
)}
{item.category === 'PIPE' && (
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
사양에 포함
</span>
)}
</td>
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right' }}>
{item.category === 'PIPE' ?
`${Math.round(item.bom_quantity)}mm` :
item.bom_quantity
}
</td>
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right', fontWeight: '600' }}>
{item.category === 'PIPE' ?
`${item.pipes_count}본 (${Math.round(item.calculated_qty)}mm)` :
item.calculated_qty
}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{item.unit}
</td>
<td style={{ padding: '12px', fontSize: '12px', color: '#718096' }}>
{item.category === 'PIPE' && (
<div>
<div>절단수: {item.cutting_count}</div>
<div>절단손실: {item.cutting_loss}mm</div>
<div>활용률: {Math.round(item.utilization_rate)}%</div>
</div>
)}
{item.category !== 'PIPE' && item.safety_factor && (
<div>여유율: {Math.round((item.safety_factor - 1) * 100)}%</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{
marginTop: '24px',
padding: '16px',
background: '#f7fafc',
borderRadius: '8px',
fontSize: '14px',
color: '#4a5568'
}}>
<div style={{ fontWeight: '600', marginBottom: '8px' }}>📋 계산 규칙 (올바른 규칙):</div>
<div> <strong>PIPE:</strong> 6M 단위 올림, 절단당 2mm 손실</div>
<div> <strong>FITTING:</strong> BOM 수량 그대로</div>
<div> <strong>VALVE:</strong> BOM 수량 그대로</div>
<div> <strong>BOLT:</strong> 5% 여유율 4 배수 올림</div>
<div> <strong>GASKET:</strong> 5 배수 올림</div>
<div> <strong>INSTRUMENT:</strong> BOM 수량 그대로</div>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default SimpleMaterialsPage;

View File

@@ -0,0 +1,430 @@
.user-management-page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
padding-bottom: 16px;
border-bottom: 2px solid #e2e8f0;
}
.page-header h1 {
color: #2d3748;
font-size: 28px;
font-weight: 700;
margin: 0 0 8px 0;
}
.page-header p {
color: #718096;
font-size: 16px;
margin: 0;
}
.create-user-btn {
padding: 12px 24px;
background: linear-gradient(135deg, #48bb78 0%, #38a169 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.create-user-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(72, 187, 120, 0.3);
}
.error-message {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fed7d7;
border: 1px solid #feb2b2;
border-radius: 8px;
color: #c53030;
margin-bottom: 24px;
}
.close-error {
margin-left: auto;
background: none;
border: none;
color: #c53030;
cursor: pointer;
font-size: 16px;
}
.access-denied {
text-align: center;
padding: 64px 24px;
color: #718096;
}
.access-denied h2 {
color: #2d3748;
margin-bottom: 16px;
}
/* 모달 스타일 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.user-form-modal {
background: white;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
width: 100%;
max-width: 800px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 32px;
border-bottom: 1px solid #e2e8f0;
}
.modal-header h2 {
color: #2d3748;
font-size: 24px;
font-weight: 700;
margin: 0;
}
.close-modal {
background: none;
border: none;
font-size: 24px;
color: #718096;
cursor: pointer;
padding: 4px;
}
.close-modal:hover {
color: #2d3748;
}
/* 폼 스타일 */
.user-form-modal form {
padding: 32px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
color: #2d3748;
font-weight: 600;
font-size: 14px;
}
.form-group input,
.form-group select {
padding: 12px 16px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 16px;
transition: all 0.2s ease;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-group input:disabled {
background: #f1f5f9;
color: #94a3b8;
cursor: not-allowed;
}
.checkbox-group {
flex-direction: row;
align-items: center;
gap: 12px;
}
.checkbox-group input {
width: auto;
margin: 0;
}
/* 권한 설정 섹션 */
.permissions-section {
margin-bottom: 32px;
padding-top: 24px;
border-top: 1px solid #e2e8f0;
}
.permissions-section h3 {
color: #2d3748;
font-size: 18px;
font-weight: 600;
margin: 0 0 20px 0;
}
.permissions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 24px;
}
.permission-category {
background: #f7fafc;
border-radius: 8px;
padding: 16px;
}
.permission-category h4 {
color: #2d3748;
font-size: 14px;
font-weight: 600;
margin: 0 0 12px 0;
padding-bottom: 8px;
border-bottom: 1px solid #e2e8f0;
}
.permission-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 14px;
color: #4a5568;
cursor: pointer;
}
.permission-item input {
margin: 0;
width: auto;
}
.permission-item:hover {
color: #2d3748;
}
/* 폼 액션 버튼 */
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
padding-top: 24px;
border-top: 1px solid #e2e8f0;
}
.submit-btn {
padding: 12px 32px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.submit-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
}
.cancel-btn {
padding: 12px 32px;
background: #e2e8f0;
color: #4a5568;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.cancel-btn:hover {
background: #cbd5e0;
}
/* 사용자 테이블 */
.users-list {
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.users-table {
overflow-x: auto;
}
.users-table table {
width: 100%;
border-collapse: collapse;
}
.users-table th {
background: #f7fafc;
color: #2d3748;
font-weight: 600;
font-size: 14px;
padding: 16px 12px;
text-align: left;
border-bottom: 2px solid #e2e8f0;
}
.users-table td {
padding: 16px 12px;
border-bottom: 1px solid #e2e8f0;
font-size: 14px;
color: #4a5568;
}
.users-table tr:hover {
background: #f7fafc;
}
/* 배지 스타일 */
.role-badge,
.access-badge,
.status-badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.role-admin { background: #fed7d7; color: #c53030; }
.role-system { background: #fbb6ce; color: #b83280; }
.role-leader { background: #bee3f8; color: #2b6cb0; }
.role-support { background: #c6f6d5; color: #2f855a; }
.role-user { background: #e2e8f0; color: #4a5568; }
.access-manager { background: #fed7d7; color: #c53030; }
.access-leader { background: #bee3f8; color: #2b6cb0; }
.access-worker { background: #c6f6d5; color: #2f855a; }
.access-viewer { background: #faf089; color: #744210; }
.status-badge.active { background: #c6f6d5; color: #2f855a; }
.status-badge.inactive { background: #fed7d7; color: #c53030; }
/* 액션 버튼 */
.action-buttons {
display: flex;
gap: 8px;
}
.edit-btn,
.toggle-btn {
padding: 6px 8px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
}
.edit-btn {
background: #bee3f8;
color: #2b6cb0;
}
.edit-btn:hover {
background: #90cdf4;
}
.toggle-btn.deactivate {
background: #fed7d7;
color: #c53030;
}
.toggle-btn.activate {
background: #c6f6d5;
color: #2f855a;
}
.toggle-btn:hover {
opacity: 0.8;
}
.loading {
text-align: center;
padding: 64px 24px;
color: #718096;
font-size: 16px;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.user-management-page {
padding: 16px;
}
.page-header {
flex-direction: column;
gap: 16px;
align-items: stretch;
}
.form-grid {
grid-template-columns: 1fr;
}
.permissions-grid {
grid-template-columns: 1fr;
}
.users-table {
font-size: 12px;
}
.users-table th,
.users-table td {
padding: 8px 6px;
}
.form-actions {
flex-direction: column;
}
.submit-btn,
.cancel-btn {
width: 100%;
}
}

View File

@@ -0,0 +1,499 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import api from '../api';
import './UserManagementPage.css';
const UserManagementPage = () => {
const { user, hasPermission, isAdmin } = useAuth();
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [formData, setFormData] = useState({
username: '',
password: '',
name: '',
email: '',
role: 'user',
access_level: 'worker',
department: '',
position: '',
phone: '',
is_active: true,
permissions: []
});
// 권한 목록 정의
const availablePermissions = [
{ id: 'bom.view', name: 'BOM 조회', category: 'BOM' },
{ id: 'bom.edit', name: 'BOM 편집', category: 'BOM' },
{ id: 'bom.delete', name: 'BOM 삭제', category: 'BOM' },
{ id: 'project.view', name: '프로젝트 조회', category: '프로젝트' },
{ id: 'project.create', name: '프로젝트 생성', category: '프로젝트' },
{ id: 'project.edit', name: '프로젝트 편집', category: '프로젝트' },
{ id: 'project.delete', name: '프로젝트 삭제', category: '프로젝트' },
{ id: 'file.upload', name: '파일 업로드', category: '파일' },
{ id: 'file.download', name: '파일 다운로드', category: '파일' },
{ id: 'file.delete', name: '파일 삭제', category: '파일' },
{ id: 'user.view', name: '사용자 조회', category: '사용자' },
{ id: 'user.create', name: '사용자 생성', category: '사용자' },
{ id: 'user.edit', name: '사용자 편집', category: '사용자' },
{ id: 'user.delete', name: '사용자 삭제', category: '사용자' },
{ id: 'system.admin', name: '시스템 관리', category: '시스템' }
];
const roleOptions = [
{ value: 'admin', label: '관리자', description: '모든 권한' },
{ value: 'system', label: '시스템', description: '시스템 관리' },
{ value: 'leader', label: '팀장', description: '팀 관리' },
{ value: 'support', label: '지원', description: '지원 업무' },
{ value: 'user', label: '사용자', description: '일반 사용자' }
];
const accessLevelOptions = [
{ value: 'manager', label: '관리자', description: '전체 관리 권한' },
{ value: 'leader', label: '팀장', description: '팀 관리 권한' },
{ value: 'worker', label: '작업자', description: '기본 작업 권한' },
{ value: 'viewer', label: '조회자', description: '조회 전용' }
];
// 사용자 목록 조회
const fetchUsers = async () => {
try {
setIsLoading(true);
const response = await api.get('/auth/users');
if (response.data.success) {
setUsers(response.data.users);
}
} catch (error) {
console.error('Failed to fetch users:', error);
setError('사용자 목록을 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (hasPermission('user.view') || isAdmin()) {
fetchUsers();
}
}, []);
// 폼 데이터 변경 처리
const handleFormChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
// 권한 변경 처리
const handlePermissionChange = (permissionId, checked) => {
setFormData(prev => ({
...prev,
permissions: checked
? [...prev.permissions, permissionId]
: prev.permissions.filter(p => p !== permissionId)
}));
};
// 사용자 생성
const handleCreateUser = async (e) => {
e.preventDefault();
try {
const response = await api.post('/auth/register', formData);
if (response.data.success) {
setShowCreateForm(false);
setFormData({
username: '',
password: '',
name: '',
email: '',
role: 'user',
access_level: 'worker',
department: '',
position: '',
phone: '',
is_active: true,
permissions: []
});
await fetchUsers();
}
} catch (error) {
console.error('Failed to create user:', error);
setError(error.response?.data?.error?.message || '사용자 생성에 실패했습니다.');
}
};
// 사용자 편집
const handleEditUser = (userData) => {
setEditingUser(userData.user_id);
setFormData({
username: userData.username,
password: '',
name: userData.name,
email: userData.email,
role: userData.role,
access_level: userData.access_level,
department: userData.department || '',
position: userData.position || '',
phone: userData.phone || '',
is_active: userData.is_active,
permissions: userData.permissions || []
});
setShowCreateForm(true);
};
// 사용자 업데이트
const handleUpdateUser = async (e) => {
e.preventDefault();
try {
const updateData = { ...formData };
if (!updateData.password) {
delete updateData.password; // 비밀번호가 비어있으면 제외
}
const response = await api.put(`/auth/users/${editingUser}`, updateData);
if (response.data.success) {
setShowCreateForm(false);
setEditingUser(null);
setFormData({
username: '',
password: '',
name: '',
email: '',
role: 'user',
access_level: 'worker',
department: '',
position: '',
phone: '',
is_active: true,
permissions: []
});
await fetchUsers();
}
} catch (error) {
console.error('Failed to update user:', error);
setError(error.response?.data?.error?.message || '사용자 수정에 실패했습니다.');
}
};
// 사용자 활성화/비활성화
const handleToggleUserStatus = async (userId, currentStatus) => {
try {
const response = await api.put(`/auth/users/${userId}`, {
is_active: !currentStatus
});
if (response.data.success) {
await fetchUsers();
}
} catch (error) {
console.error('Failed to toggle user status:', error);
setError('사용자 상태 변경에 실패했습니다.');
}
};
if (!hasPermission('user.view') && !isAdmin()) {
return (
<div className="access-denied">
<h2>접근 권한이 없습니다</h2>
<p>사용자 관리 페이지에 접근할 권한이 없습니다.</p>
</div>
);
}
return (
<div className="user-management-page">
<div className="page-header">
<h1>👥 사용자 관리</h1>
<p>시스템 사용자 계정을 관리하고 권한을 설정합니다.</p>
{(hasPermission('user.create') || isAdmin()) && (
<button
className="create-user-btn"
onClick={() => setShowCreateForm(true)}
>
사용자 생성
</button>
)}
</div>
{error && (
<div className="error-message">
<span className="error-icon"></span>
{error}
<button onClick={() => setError('')} className="close-error"></button>
</div>
)}
{/* 사용자 생성/편집 폼 */}
{showCreateForm && (
<div className="modal-overlay">
<div className="user-form-modal">
<div className="modal-header">
<h2>{editingUser ? '사용자 편집' : '새 사용자 생성'}</h2>
<button
className="close-modal"
onClick={() => {
setShowCreateForm(false);
setEditingUser(null);
}}
>
</button>
</div>
<form onSubmit={editingUser ? handleUpdateUser : handleCreateUser}>
<div className="form-grid">
<div className="form-group">
<label>사용자명 *</label>
<input
type="text"
name="username"
value={formData.username}
onChange={handleFormChange}
required
disabled={editingUser} // 편집 사용자명 변경 불가
/>
</div>
<div className="form-group">
<label>비밀번호 {!editingUser && '*'}</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleFormChange}
required={!editingUser}
placeholder={editingUser ? '변경하지 않으려면 비워두세요' : ''}
/>
</div>
<div className="form-group">
<label>이름 *</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleFormChange}
required
/>
</div>
<div className="form-group">
<label>이메일 *</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleFormChange}
required
/>
</div>
<div className="form-group">
<label>역할</label>
<select
name="role"
value={formData.role}
onChange={handleFormChange}
>
{roleOptions.map(role => (
<option key={role.value} value={role.value}>
{role.label} - {role.description}
</option>
))}
</select>
</div>
<div className="form-group">
<label>접근 레벨</label>
<select
name="access_level"
value={formData.access_level}
onChange={handleFormChange}
>
{accessLevelOptions.map(level => (
<option key={level.value} value={level.value}>
{level.label} - {level.description}
</option>
))}
</select>
</div>
<div className="form-group">
<label>부서</label>
<input
type="text"
name="department"
value={formData.department}
onChange={handleFormChange}
/>
</div>
<div className="form-group">
<label>직책</label>
<input
type="text"
name="position"
value={formData.position}
onChange={handleFormChange}
/>
</div>
<div className="form-group">
<label>전화번호</label>
<input
type="tel"
name="phone"
value={formData.phone}
onChange={handleFormChange}
/>
</div>
<div className="form-group checkbox-group">
<label>
<input
type="checkbox"
name="is_active"
checked={formData.is_active}
onChange={handleFormChange}
/>
계정 활성화
</label>
</div>
</div>
{/* 권한 설정 */}
<div className="permissions-section">
<h3>권한 설정</h3>
<div className="permissions-grid">
{Object.entries(
availablePermissions.reduce((acc, perm) => {
if (!acc[perm.category]) acc[perm.category] = [];
acc[perm.category].push(perm);
return acc;
}, {})
).map(([category, perms]) => (
<div key={category} className="permission-category">
<h4>{category}</h4>
{perms.map(perm => (
<label key={perm.id} className="permission-item">
<input
type="checkbox"
checked={formData.permissions.includes(perm.id)}
onChange={(e) => handlePermissionChange(perm.id, e.target.checked)}
/>
{perm.name}
</label>
))}
</div>
))}
</div>
</div>
<div className="form-actions">
<button type="submit" className="submit-btn">
{editingUser ? '수정' : '생성'}
</button>
<button
type="button"
className="cancel-btn"
onClick={() => {
setShowCreateForm(false);
setEditingUser(null);
}}
>
취소
</button>
</div>
</form>
</div>
</div>
)}
{/* 사용자 목록 */}
<div className="users-list">
{isLoading ? (
<div className="loading">사용자 목록을 불러오는 ...</div>
) : (
<div className="users-table">
<table>
<thead>
<tr>
<th>사용자명</th>
<th>이름</th>
<th>이메일</th>
<th>역할</th>
<th>접근 레벨</th>
<th>부서</th>
<th>상태</th>
<th>마지막 로그인</th>
<th>작업</th>
</tr>
</thead>
<tbody>
{users.map(userData => (
<tr key={userData.user_id}>
<td>{userData.username}</td>
<td>{userData.name}</td>
<td>{userData.email}</td>
<td>
<span className={`role-badge role-${userData.role}`}>
{roleOptions.find(r => r.value === userData.role)?.label}
</span>
</td>
<td>
<span className={`access-badge access-${userData.access_level}`}>
{accessLevelOptions.find(l => l.value === userData.access_level)?.label}
</span>
</td>
<td>{userData.department || '-'}</td>
<td>
<span className={`status-badge ${userData.is_active ? 'active' : 'inactive'}`}>
{userData.is_active ? '활성' : '비활성'}
</span>
</td>
<td>
{userData.last_login_at
? new Date(userData.last_login_at).toLocaleString('ko-KR')
: '없음'
}
</td>
<td>
<div className="action-buttons">
{(hasPermission('user.edit') || isAdmin()) && (
<button
className="edit-btn"
onClick={() => handleEditUser(userData)}
title="편집"
>
</button>
)}
{(hasPermission('user.edit') || isAdmin()) && (
<button
className={`toggle-btn ${userData.is_active ? 'deactivate' : 'activate'}`}
onClick={() => handleToggleUserStatus(userData.user_id, userData.is_active)}
title={userData.is_active ? '비활성화' : '활성화'}
>
{userData.is_active ? '🔒' : '🔓'}
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
};
export default UserManagementPage;