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:
Hyungi Ahn
2025-12-06 07:36:44 +09:00
parent c258303bb7
commit 17843e285f
12 changed files with 2759 additions and 83 deletions

View File

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

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

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

View File

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