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

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

View File

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