feat: 리비전 관리 시스템 및 구매확정 기능 구현
- 리비전 관리 라우터 및 서비스 추가 (revision_management.py, revision_comparison_service.py, revision_session_service.py) - 구매확정 기능 구현: materials 테이블에 purchase_confirmed 필드 추가 및 업데이트 로직 - 리비전 비교 로직 구현: 구매확정된 자재 기반으로 신규/변경 자재 자동 분류 - 데이터베이스 스키마 확장: revision_sessions, revision_material_changes, inventory_transfers 테이블 추가 - 구매신청 생성 시 자재 상세 정보 저장 및 purchase_confirmed 자동 업데이트 - 프론트엔드: 리비전 관리 컴포넌트 및 hooks 추가 - 파일 목록 조회 API 추가 (/files/list) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -12,36 +12,38 @@ const BOMFilesTab = ({
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [revisionDialog, setRevisionDialog] = useState({ open: false, file: null });
|
||||
const [groupedFiles, setGroupedFiles] = useState({});
|
||||
|
||||
// BOM 파일 목록 로드 함수
|
||||
const loadBOMFiles = async () => {
|
||||
if (!selectedProject) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const projectCode = selectedProject.official_project_code || selectedProject.job_no;
|
||||
const encodedProjectCode = encodeURIComponent(projectCode);
|
||||
const response = await api.get(`/files/project/${encodedProjectCode}`);
|
||||
const files = response.data || [];
|
||||
|
||||
setBomFiles(files);
|
||||
|
||||
// BOM 이름별로 그룹화
|
||||
const groups = groupFilesByBOM(files);
|
||||
setGroupedFiles(groups);
|
||||
|
||||
} catch (err) {
|
||||
console.error('BOM 파일 로드 실패:', err);
|
||||
setError('BOM 파일을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// BOM 파일 목록 로드
|
||||
useEffect(() => {
|
||||
const loadBOMFiles = async () => {
|
||||
if (!selectedProject) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const projectCode = selectedProject.official_project_code || selectedProject.job_no;
|
||||
const encodedProjectCode = encodeURIComponent(projectCode);
|
||||
const response = await api.get(`/files/project/${encodedProjectCode}`);
|
||||
const files = response.data || [];
|
||||
|
||||
setBomFiles(files);
|
||||
|
||||
// BOM 이름별로 그룹화
|
||||
const groups = groupFilesByBOM(files);
|
||||
setGroupedFiles(groups);
|
||||
|
||||
} catch (err) {
|
||||
console.error('BOM 파일 로드 실패:', err);
|
||||
setError('BOM 파일을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadBOMFiles();
|
||||
}, [selectedProject, refreshTrigger, setBomFiles]);
|
||||
|
||||
@@ -99,10 +101,48 @@ const BOMFilesTab = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 리비전 업로드 (향후 구현)
|
||||
// 리비전 업로드
|
||||
const handleRevisionUpload = (parentFile) => {
|
||||
// TODO: 리비전 업로드 기능 구현
|
||||
alert('리비전 업로드 기능은 향후 구현 예정입니다.');
|
||||
setRevisionDialog({
|
||||
open: true,
|
||||
file: parentFile
|
||||
});
|
||||
};
|
||||
|
||||
// 리비전 업로드 성공 핸들러
|
||||
const handleRevisionUploadSuccess = () => {
|
||||
setRevisionDialog({ open: false, file: null });
|
||||
// BOM 파일 목록 새로고침
|
||||
loadBOMFiles();
|
||||
};
|
||||
|
||||
// 파일 업로드 처리
|
||||
const handleFileUpload = async (file) => {
|
||||
if (!file || !revisionDialog.file) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('job_no', selectedProject.job_no);
|
||||
formData.append('parent_file_id', revisionDialog.file.id);
|
||||
formData.append('bom_name', revisionDialog.file.bom_name || revisionDialog.file.original_filename);
|
||||
|
||||
const response = await api.post('/files/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
alert(`리비전 업로드 성공! ${response.data.revision}`);
|
||||
handleRevisionUploadSuccess();
|
||||
} else {
|
||||
alert(response.data.message || '리비전 업로드에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('리비전 업로드 실패:', error);
|
||||
alert('리비전 업로드에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 포맷팅
|
||||
@@ -422,6 +462,73 @@ const BOMFilesTab = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리비전 업로드 다이얼로그 */}
|
||||
{revisionDialog.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: '500px',
|
||||
width: '90%',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600' }}>
|
||||
📝 리비전 업로드: {revisionDialog.file?.original_filename || 'BOM 파일'}
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: '16px', fontSize: '14px', color: '#6b7280' }}>
|
||||
새로운 리비전 파일을 선택해주세요.
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginBottom: '16px',
|
||||
padding: '8px',
|
||||
border: '2px dashed #d1d5db',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setRevisionDialog({ open: false, file: null })}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#f3f4f6',
|
||||
color: '#374151',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
541
frontend/src/components/revision/RevisionManagementPanel.jsx
Normal file
541
frontend/src/components/revision/RevisionManagementPanel.jsx
Normal file
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* 리비전 관리 패널 컴포넌트
|
||||
* BOM 관리 페이지에 통합되어 리비전 기능을 제공
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRevisionManagement } from '../../hooks/useRevisionManagement';
|
||||
|
||||
const RevisionManagementPanel = ({
|
||||
jobNo,
|
||||
currentFileId,
|
||||
previousFileId,
|
||||
onRevisionComplete,
|
||||
onRevisionCancel
|
||||
}) => {
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
currentSession,
|
||||
sessionStatus,
|
||||
createRevisionSession,
|
||||
getSessionStatus,
|
||||
compareCategory,
|
||||
getSessionChanges,
|
||||
processRevisionAction,
|
||||
completeSession,
|
||||
cancelSession,
|
||||
getRevisionSummary,
|
||||
getSupportedCategories,
|
||||
clearError
|
||||
} = useRevisionManagement();
|
||||
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||
const [categoryChanges, setCategoryChanges] = useState({});
|
||||
const [revisionSummary, setRevisionSummary] = useState(null);
|
||||
const [processingActions, setProcessingActions] = useState(new Set());
|
||||
|
||||
// 컴포넌트 초기화
|
||||
useEffect(() => {
|
||||
initializeRevisionPanel();
|
||||
}, [currentFileId, previousFileId]);
|
||||
|
||||
// 세션 상태 모니터링
|
||||
useEffect(() => {
|
||||
if (currentSession?.session_id) {
|
||||
const interval = setInterval(() => {
|
||||
refreshSessionStatus();
|
||||
}, 5000); // 5초마다 상태 갱신
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [currentSession]);
|
||||
|
||||
const initializeRevisionPanel = async () => {
|
||||
try {
|
||||
// 지원 카테고리 로드
|
||||
const categoriesResult = await getSupportedCategories();
|
||||
if (categoriesResult.success) {
|
||||
setCategories(categoriesResult.data);
|
||||
}
|
||||
|
||||
// 리비전 세션 생성
|
||||
if (currentFileId && previousFileId) {
|
||||
const sessionResult = await createRevisionSession(jobNo, currentFileId, previousFileId);
|
||||
if (sessionResult.success) {
|
||||
console.log('✅ 리비전 세션 생성 완료');
|
||||
await refreshSessionStatus();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('리비전 패널 초기화 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSessionStatus = async () => {
|
||||
if (currentSession?.session_id) {
|
||||
try {
|
||||
await getSessionStatus(currentSession.session_id);
|
||||
await loadRevisionSummary();
|
||||
} catch (error) {
|
||||
console.error('세션 상태 갱신 실패:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadRevisionSummary = async () => {
|
||||
if (currentSession?.session_id) {
|
||||
try {
|
||||
const summaryResult = await getRevisionSummary(currentSession.session_id);
|
||||
if (summaryResult.success) {
|
||||
setRevisionSummary(summaryResult.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('리비전 요약 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryCompare = async (category) => {
|
||||
if (!currentSession?.session_id) return;
|
||||
|
||||
try {
|
||||
const result = await compareCategory(currentSession.session_id, category);
|
||||
if (result.success) {
|
||||
// 변경사항 로드
|
||||
const changesResult = await getSessionChanges(currentSession.session_id, category);
|
||||
if (changesResult.success) {
|
||||
setCategoryChanges(prev => ({
|
||||
...prev,
|
||||
[category]: changesResult.data.changes
|
||||
}));
|
||||
}
|
||||
|
||||
await refreshSessionStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`카테고리 ${category} 비교 실패:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActionProcess = async (changeId, action, notes = '') => {
|
||||
setProcessingActions(prev => new Set(prev).add(changeId));
|
||||
|
||||
try {
|
||||
const result = await processRevisionAction(changeId, action, notes);
|
||||
if (result.success) {
|
||||
// 해당 카테고리 변경사항 새로고침
|
||||
if (selectedCategory) {
|
||||
const changesResult = await getSessionChanges(currentSession.session_id, selectedCategory);
|
||||
if (changesResult.success) {
|
||||
setCategoryChanges(prev => ({
|
||||
...prev,
|
||||
[selectedCategory]: changesResult.data.changes
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
await refreshSessionStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('액션 처리 실패:', error);
|
||||
} finally {
|
||||
setProcessingActions(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(changeId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteRevision = async () => {
|
||||
if (!currentSession?.session_id) return;
|
||||
|
||||
try {
|
||||
const result = await completeSession(currentSession.session_id);
|
||||
if (result.success) {
|
||||
onRevisionComplete?.(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('리비전 완료 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelRevision = async (reason = '') => {
|
||||
if (!currentSession?.session_id) return;
|
||||
|
||||
try {
|
||||
const result = await cancelSession(currentSession.session_id, reason);
|
||||
if (result.success) {
|
||||
onRevisionCancel?.(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('리비전 취소 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getActionColor = (action) => {
|
||||
const colors = {
|
||||
'new_material': '#10b981',
|
||||
'additional_purchase': '#f59e0b',
|
||||
'inventory_transfer': '#8b5cf6',
|
||||
'purchase_cancel': '#ef4444',
|
||||
'quantity_update': '#3b82f6',
|
||||
'maintain': '#6b7280'
|
||||
};
|
||||
return colors[action] || '#6b7280';
|
||||
};
|
||||
|
||||
const getActionLabel = (action) => {
|
||||
const labels = {
|
||||
'new_material': '신규 자재',
|
||||
'additional_purchase': '추가 구매',
|
||||
'inventory_transfer': '재고 이관',
|
||||
'purchase_cancel': '구매 취소',
|
||||
'quantity_update': '수량 업데이트',
|
||||
'maintain': '유지'
|
||||
};
|
||||
return labels[action] || action;
|
||||
};
|
||||
|
||||
if (!currentSession) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
background: '#f8fafc',
|
||||
borderRadius: '12px',
|
||||
border: '2px dashed #cbd5e1'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', color: '#64748b', marginBottom: '8px' }}>
|
||||
🔄 리비전 세션 초기화 중...
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#94a3b8' }}>
|
||||
자재 비교를 위한 세션을 준비하고 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
padding: '20px 24px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: '20px', fontWeight: '600' }}>
|
||||
📊 리비전 관리
|
||||
</h3>
|
||||
<p style={{ margin: '4px 0 0 0', fontSize: '14px', opacity: 0.9 }}>
|
||||
Job: {jobNo} | 세션 ID: {currentSession.session_id}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handleCompleteRevision}
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: '#10b981',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
opacity: loading ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
✅ 완료
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCancelRevision('사용자 요청')}
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: '#ef4444',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
opacity: loading ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
❌ 취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
color: '#dc2626',
|
||||
padding: '12px 24px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span>⚠️ {error}</span>
|
||||
<button
|
||||
onClick={clearError}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#dc2626',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 진행 상황 */}
|
||||
{sessionStatus && (
|
||||
<div style={{ padding: '20px 24px', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: '16px',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#10b981' }}>
|
||||
{sessionStatus.session_info.added_count || 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b' }}>추가</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#ef4444' }}>
|
||||
{sessionStatus.session_info.removed_count || 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b' }}>제거</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#f59e0b' }}>
|
||||
{sessionStatus.session_info.changed_count || 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b' }}>변경</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#6b7280' }}>
|
||||
{sessionStatus.session_info.unchanged_count || 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b' }}>유지</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 진행률 바 */}
|
||||
<div style={{
|
||||
background: '#f1f5f9',
|
||||
borderRadius: '8px',
|
||||
height: '8px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, #10b981 0%, #3b82f6 100%)',
|
||||
height: '100%',
|
||||
width: `${sessionStatus.progress_percentage || 0}%`,
|
||||
transition: 'width 0.3s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
fontSize: '12px',
|
||||
color: '#64748b',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
진행률: {Math.round(sessionStatus.progress_percentage || 0)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 탭 */}
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
|
||||
gap: '8px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
{categories.map(category => {
|
||||
const hasChanges = revisionSummary?.category_summaries?.[category.key];
|
||||
const isActive = selectedCategory === category.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.key}
|
||||
onClick={() => {
|
||||
setSelectedCategory(category.key);
|
||||
if (!categoryChanges[category.key]) {
|
||||
handleCategoryCompare(category.key);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: isActive
|
||||
? 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)'
|
||||
: hasChanges
|
||||
? 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)'
|
||||
: 'white',
|
||||
color: isActive ? 'white' : hasChanges ? '#92400e' : '#64748b',
|
||||
border: isActive ? 'none' : '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{category.name}
|
||||
{hasChanges && (
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
right: '-4px',
|
||||
background: '#ef4444',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
fontSize: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
{hasChanges.total_changes}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 선택된 카테고리의 변경사항 */}
|
||||
{selectedCategory && categoryChanges[selectedCategory] && (
|
||||
<div style={{
|
||||
background: '#f8fafc',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
<h4 style={{
|
||||
margin: '0 0 16px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#1e293b'
|
||||
}}>
|
||||
{categories.find(c => c.key === selectedCategory)?.name} 변경사항
|
||||
</h4>
|
||||
|
||||
{categoryChanges[selectedCategory].map((change, index) => (
|
||||
<div
|
||||
key={change.id || index}
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
marginBottom: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#1e293b',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{change.material_description}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#64748b',
|
||||
display: 'flex',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<span>이전: {change.previous_quantity || 0}</span>
|
||||
<span>현재: {change.current_quantity || 0}</span>
|
||||
<span>차이: {change.quantity_difference > 0 ? '+' : ''}{change.quantity_difference}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span
|
||||
style={{
|
||||
background: getActionColor(change.revision_action),
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{getActionLabel(change.revision_action)}
|
||||
</span>
|
||||
|
||||
{change.action_status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleActionProcess(change.id, change.revision_action)}
|
||||
disabled={processingActions.has(change.id)}
|
||||
style={{
|
||||
background: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '11px',
|
||||
cursor: processingActions.has(change.id) ? 'not-allowed' : 'pointer',
|
||||
opacity: processingActions.has(change.id) ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
{processingActions.has(change.id) ? '처리중...' : '처리'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{change.action_status === 'completed' && (
|
||||
<span style={{
|
||||
color: '#10b981',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
✅ 완료
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RevisionManagementPanel;
|
||||
318
frontend/src/hooks/useRevisionManagement.js
Normal file
318
frontend/src/hooks/useRevisionManagement.js
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 리비전 관리 훅
|
||||
* - 리비전 세션 생성, 관리, 완료
|
||||
* - 자재 비교 및 변경사항 처리
|
||||
* - 리비전 히스토리 조회
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
export const useRevisionManagement = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [currentSession, setCurrentSession] = useState(null);
|
||||
const [sessionStatus, setSessionStatus] = useState(null);
|
||||
const [revisionHistory, setRevisionHistory] = useState([]);
|
||||
|
||||
// 에러 처리 헬퍼
|
||||
const handleError = useCallback((error, defaultMessage) => {
|
||||
console.error(defaultMessage, error);
|
||||
const errorMessage = error.response?.data?.detail || error.message || defaultMessage;
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
}, []);
|
||||
|
||||
// 리비전 세션 생성
|
||||
const createRevisionSession = useCallback(async (jobNo, currentFileId, previousFileId) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api.post('/revision-management/sessions', {
|
||||
job_no: jobNo,
|
||||
current_file_id: currentFileId,
|
||||
previous_file_id: previousFileId
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setCurrentSession(response.data.data);
|
||||
console.log('✅ 리비전 세션 생성 완료:', response.data.data);
|
||||
return { success: true, data: response.data.data };
|
||||
} else {
|
||||
return handleError(new Error(response.data.message), '리비전 세션 생성 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
return handleError(error, '리비전 세션 생성 중 오류 발생');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [handleError]);
|
||||
|
||||
// 세션 상태 조회
|
||||
const getSessionStatus = useCallback(async (sessionId) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api.get(`/revision-management/sessions/${sessionId}`);
|
||||
|
||||
if (response.data.success) {
|
||||
setSessionStatus(response.data.data);
|
||||
console.log('✅ 세션 상태 조회 완료:', response.data.data);
|
||||
return { success: true, data: response.data.data };
|
||||
} else {
|
||||
return handleError(new Error(response.data.message), '세션 상태 조회 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
return handleError(error, '세션 상태 조회 중 오류 발생');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [handleError]);
|
||||
|
||||
// 카테고리별 자재 비교
|
||||
const compareCategory = useCallback(async (sessionId, category) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api.post(`/revision-management/sessions/${sessionId}/compare/${category}`);
|
||||
|
||||
if (response.data.success) {
|
||||
console.log(`✅ 카테고리 ${category} 비교 완료:`, response.data.data);
|
||||
return { success: true, data: response.data.data };
|
||||
} else {
|
||||
return handleError(new Error(response.data.message), `카테고리 ${category} 비교 실패`);
|
||||
}
|
||||
} catch (error) {
|
||||
return handleError(error, `카테고리 ${category} 비교 중 오류 발생`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [handleError]);
|
||||
|
||||
// 세션 변경사항 조회
|
||||
const getSessionChanges = useCallback(async (sessionId, category = null) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const params = category ? { category } : {};
|
||||
const response = await api.get(`/revision-management/sessions/${sessionId}/changes`, { params });
|
||||
|
||||
if (response.data.success) {
|
||||
console.log('✅ 세션 변경사항 조회 완료:', response.data.data);
|
||||
return { success: true, data: response.data.data };
|
||||
} else {
|
||||
return handleError(new Error(response.data.message), '세션 변경사항 조회 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
return handleError(error, '세션 변경사항 조회 중 오류 발생');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [handleError]);
|
||||
|
||||
// 리비전 액션 처리
|
||||
const processRevisionAction = useCallback(async (changeId, action, notes = null) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api.post(`/revision-management/changes/${changeId}/process`, {
|
||||
action,
|
||||
notes
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log('✅ 리비전 액션 처리 완료:', response.data.data);
|
||||
return { success: true, data: response.data.data };
|
||||
} else {
|
||||
return handleError(new Error(response.data.message), '리비전 액션 처리 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
return handleError(error, '리비전 액션 처리 중 오류 발생');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [handleError]);
|
||||
|
||||
// 세션 완료
|
||||
const completeSession = useCallback(async (sessionId) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api.post(`/revision-management/sessions/${sessionId}/complete`);
|
||||
|
||||
if (response.data.success) {
|
||||
setCurrentSession(null);
|
||||
setSessionStatus(null);
|
||||
console.log('✅ 리비전 세션 완료:', response.data.data);
|
||||
return { success: true, data: response.data.data };
|
||||
} else {
|
||||
return handleError(new Error(response.data.message), '리비전 세션 완료 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
return handleError(error, '리비전 세션 완료 중 오류 발생');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [handleError]);
|
||||
|
||||
// 세션 취소
|
||||
const cancelSession = useCallback(async (sessionId, reason = null) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const params = reason ? { reason } : {};
|
||||
const response = await api.post(`/revision-management/sessions/${sessionId}/cancel`, null, { params });
|
||||
|
||||
if (response.data.success) {
|
||||
setCurrentSession(null);
|
||||
setSessionStatus(null);
|
||||
console.log('✅ 리비전 세션 취소:', response.data.data);
|
||||
return { success: true, data: response.data.data };
|
||||
} else {
|
||||
return handleError(new Error(response.data.message), '리비전 세션 취소 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
return handleError(error, '리비전 세션 취소 중 오류 발생');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [handleError]);
|
||||
|
||||
// 리비전 히스토리 조회
|
||||
const getRevisionHistory = useCallback(async (jobNo) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api.get(`/revision-management/history/${jobNo}`);
|
||||
|
||||
if (response.data.success) {
|
||||
setRevisionHistory(response.data.data.history);
|
||||
console.log('✅ 리비전 히스토리 조회 완료:', response.data.data);
|
||||
return { success: true, data: response.data.data };
|
||||
} else {
|
||||
return handleError(new Error(response.data.message), '리비전 히스토리 조회 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
return handleError(error, '리비전 히스토리 조회 중 오류 발생');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [handleError]);
|
||||
|
||||
// 리비전 요약 조회
|
||||
const getRevisionSummary = useCallback(async (sessionId) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api.get(`/revision-management/sessions/${sessionId}/summary`);
|
||||
|
||||
if (response.data.success) {
|
||||
console.log('✅ 리비전 요약 조회 완료:', response.data.data);
|
||||
return { success: true, data: response.data.data };
|
||||
} else {
|
||||
return handleError(new Error(response.data.message), '리비전 요약 조회 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
return handleError(error, '리비전 요약 조회 중 오류 발생');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [handleError]);
|
||||
|
||||
// 지원 카테고리 조회
|
||||
const getSupportedCategories = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get('/revision-management/categories');
|
||||
if (response.data.success) {
|
||||
return { success: true, data: response.data.data.categories };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('지원 카테고리 조회 실패:', error);
|
||||
}
|
||||
|
||||
// 기본 카테고리 반환
|
||||
return {
|
||||
success: true,
|
||||
data: [
|
||||
{ key: "PIPE", name: "배관", description: "파이프 및 배관 자재" },
|
||||
{ key: "FITTING", name: "피팅", description: "배관 연결 부품" },
|
||||
{ key: "FLANGE", name: "플랜지", description: "플랜지 및 연결 부품" },
|
||||
{ key: "VALVE", name: "밸브", description: "각종 밸브류" },
|
||||
{ key: "GASKET", name: "가스켓", description: "씰링 부품" },
|
||||
{ key: "BOLT", name: "볼트", description: "체결 부품" },
|
||||
{ key: "SUPPORT", name: "서포트", description: "지지대 및 구조물" },
|
||||
{ key: "SPECIAL", name: "특수자재", description: "특수 목적 자재" },
|
||||
{ key: "UNCLASSIFIED", name: "미분류", description: "분류되지 않은 자재" }
|
||||
]
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 리비전 액션 목록 조회
|
||||
const getRevisionActions = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get('/revision-management/actions');
|
||||
if (response.data.success) {
|
||||
return { success: true, data: response.data.data.actions };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('리비전 액션 조회 실패:', error);
|
||||
}
|
||||
|
||||
// 기본 액션 반환
|
||||
return {
|
||||
success: true,
|
||||
data: [
|
||||
{ key: "new_material", name: "신규 자재", description: "새로 추가된 자재" },
|
||||
{ key: "additional_purchase", name: "추가 구매", description: "구매된 자재의 수량 증가" },
|
||||
{ key: "inventory_transfer", name: "재고 이관", description: "구매된 자재의 수량 감소 또는 제거" },
|
||||
{ key: "purchase_cancel", name: "구매 취소", description: "미구매 자재의 제거" },
|
||||
{ key: "quantity_update", name: "수량 업데이트", description: "미구매 자재의 수량 변경" },
|
||||
{ key: "maintain", name: "유지", description: "변경사항 없음" }
|
||||
]
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 상태 초기화
|
||||
const resetState = useCallback(() => {
|
||||
setCurrentSession(null);
|
||||
setSessionStatus(null);
|
||||
setRevisionHistory([]);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 상태
|
||||
loading,
|
||||
error,
|
||||
currentSession,
|
||||
sessionStatus,
|
||||
revisionHistory,
|
||||
|
||||
// 액션
|
||||
createRevisionSession,
|
||||
getSessionStatus,
|
||||
compareCategory,
|
||||
getSessionChanges,
|
||||
processRevisionAction,
|
||||
completeSession,
|
||||
cancelSession,
|
||||
getRevisionHistory,
|
||||
getRevisionSummary,
|
||||
getSupportedCategories,
|
||||
getRevisionActions,
|
||||
resetState,
|
||||
|
||||
// 유틸리티
|
||||
clearError: () => setError(null)
|
||||
};
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SpecialMaterialsView,
|
||||
UnclassifiedMaterialsView
|
||||
} from '../components/bom';
|
||||
import RevisionManagementPanel from '../components/revision/RevisionManagementPanel';
|
||||
import './BOMManagementPage.css';
|
||||
|
||||
const BOMManagementPage = ({
|
||||
@@ -35,6 +36,11 @@ const BOMManagementPage = ({
|
||||
const [purchasedMaterials, setPurchasedMaterials] = useState(new Set());
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// 리비전 관련 상태
|
||||
const [isRevisionMode, setIsRevisionMode] = useState(false);
|
||||
const [previousFileId, setPreviousFileId] = useState(null);
|
||||
const [showRevisionPanel, setShowRevisionPanel] = useState(false);
|
||||
|
||||
// 자재 업데이트 함수 (브랜드, 사용자 요구사항 등)
|
||||
const updateMaterial = (materialId, updates) => {
|
||||
setMaterials(prevMaterials =>
|
||||
@@ -161,9 +167,64 @@ const BOMManagementPage = ({
|
||||
loadMaterials(fileId);
|
||||
loadAvailableRevisions();
|
||||
loadUserRequirements(fileId);
|
||||
checkRevisionMode(); // 리비전 모드 확인
|
||||
}
|
||||
}, [fileId]);
|
||||
|
||||
// 리비전 모드 확인
|
||||
const checkRevisionMode = async () => {
|
||||
try {
|
||||
// 현재 job_no의 모든 파일 목록 확인
|
||||
const response = await api.get(`/files/list?job_no=${jobNo}`);
|
||||
const files = response.data.files || [];
|
||||
|
||||
if (files.length > 1) {
|
||||
// 파일들을 업로드 날짜순으로 정렬
|
||||
const sortedFiles = files.sort((a, b) => new Date(a.upload_date) - new Date(b.upload_date));
|
||||
|
||||
// 현재 파일의 인덱스 찾기
|
||||
const currentIndex = sortedFiles.findIndex(file => file.id === parseInt(fileId));
|
||||
|
||||
if (currentIndex > 0) {
|
||||
// 이전 파일이 있으면 리비전 모드 활성화
|
||||
const previousFile = sortedFiles[currentIndex - 1];
|
||||
setIsRevisionMode(true);
|
||||
setPreviousFileId(previousFile.id);
|
||||
|
||||
console.log('✅ 리비전 모드 활성화:', {
|
||||
currentFileId: fileId,
|
||||
previousFileId: previousFile.id,
|
||||
currentRevision: revision,
|
||||
previousRevision: previousFile.revision
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('리비전 모드 확인 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 리비전 관리 핸들러
|
||||
const handleRevisionComplete = (revisionData) => {
|
||||
console.log('✅ 리비전 완료:', revisionData);
|
||||
setShowRevisionPanel(false);
|
||||
setIsRevisionMode(false);
|
||||
|
||||
// 자재 목록 새로고침
|
||||
loadMaterials(fileId);
|
||||
|
||||
// 성공 메시지 표시
|
||||
alert('리비전 처리가 완료되었습니다!');
|
||||
};
|
||||
|
||||
const handleRevisionCancel = (cancelData) => {
|
||||
console.log('❌ 리비전 취소:', cancelData);
|
||||
setShowRevisionPanel(false);
|
||||
|
||||
// 취소 메시지 표시
|
||||
alert('리비전 처리가 취소되었습니다.');
|
||||
};
|
||||
|
||||
// 자재 로드 후 선택된 카테고리가 유효한지 확인
|
||||
useEffect(() => {
|
||||
if (materials.length > 0) {
|
||||
@@ -282,15 +343,32 @@ const BOMManagementPage = ({
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '28px' }}>
|
||||
<div>
|
||||
<h2 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0',
|
||||
letterSpacing: '-0.025em'
|
||||
}}>
|
||||
BOM Materials Management
|
||||
</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<h2 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: 0,
|
||||
letterSpacing: '-0.025em'
|
||||
}}>
|
||||
BOM Materials Management
|
||||
</h2>
|
||||
{isRevisionMode && (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
color: 'white',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
boxShadow: '0 4px 6px -1px rgba(245, 158, 11, 0.3)'
|
||||
}}>
|
||||
📊 Revision Mode
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#64748b',
|
||||
@@ -300,31 +378,55 @@ const BOMManagementPage = ({
|
||||
{bomName} - {currentRevision} | Project: {selectedProject?.job_name || jobNo}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease',
|
||||
letterSpacing: '0.025em'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = '#f9fafb';
|
||||
e.target.style.borderColor = '#9ca3af';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = 'white';
|
||||
e.target.style.borderColor = '#d1d5db';
|
||||
}}
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
{isRevisionMode && (
|
||||
<button
|
||||
onClick={() => setShowRevisionPanel(!showRevisionPanel)}
|
||||
style={{
|
||||
background: showRevisionPanel
|
||||
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease',
|
||||
letterSpacing: '0.025em',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
>
|
||||
{showRevisionPanel ? '📊 Hide Revision Panel' : '🔄 Manage Revision'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease',
|
||||
letterSpacing: '0.025em'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = '#f9fafb';
|
||||
e.target.style.borderColor = '#9ca3af';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = 'white';
|
||||
e.target.style.borderColor = '#d1d5db';
|
||||
}}
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 정보 */}
|
||||
@@ -491,6 +593,19 @@ const BOMManagementPage = ({
|
||||
renderCategoryView()
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 리비전 관리 패널 */}
|
||||
{isRevisionMode && showRevisionPanel && (
|
||||
<div style={{ marginTop: '40px' }}>
|
||||
<RevisionManagementPanel
|
||||
jobNo={jobNo}
|
||||
currentFileId={parseInt(fileId)}
|
||||
previousFileId={previousFileId}
|
||||
onRevisionComplete={handleRevisionComplete}
|
||||
onRevisionCancel={handleRevisionCancel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user