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;
|
||||
Reference in New Issue
Block a user