리비전 페이지 제거 및 트랜잭션 오류 임시 수정
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- frontend/src/pages/revision/ 폴더 완전 삭제
- EnhancedRevisionPage.css 제거
- support_details 저장 시 트랜잭션 오류로 인해 임시로 상세 정보 저장 비활성화
- 리비전 기능 재설계 예정
This commit is contained in:
Hyungi Ahn
2025-10-21 12:11:57 +09:00
parent 8f42a1054e
commit 1dc735f362
29 changed files with 1728 additions and 6987 deletions

View File

@@ -1,14 +1,110 @@
import React from 'react';
import React, { useState, useRef } from 'react';
import api from '../api';
const RevisionUploadDialog = ({
revisionDialog,
setRevisionDialog,
revisionFile,
setRevisionFile,
handleRevisionUpload,
uploading
isOpen,
onClose,
parentFile,
selectedProject,
onUploadSuccess
}) => {
if (!revisionDialog.open) return null;
const [selectedFile, setSelectedFile] = useState(null);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [error, setError] = useState('');
const fileInputRef = useRef(null);
const handleFileSelect = (event) => {
const file = event.target.files[0];
if (file) {
// 파일 타입 검증
const allowedTypes = [
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/csv'
];
if (!allowedTypes.includes(file.type) && !file.name.match(/\.(xlsx?|csv)$/i)) {
setError('Excel 파일(.xlsx, .xls) 또는 CSV 파일만 업로드 가능합니다.');
return;
}
setSelectedFile(file);
setError('');
}
};
const handleUpload = async () => {
if (!selectedFile || !parentFile || !selectedProject) {
setError('필수 정보가 누락되었습니다.');
return;
}
try {
setUploading(true);
setUploadProgress(0);
setError('');
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('job_no', selectedProject.official_project_code || selectedProject.job_no);
formData.append('parent_file_id', parentFile.id);
formData.append('bom_name', parentFile.bom_name || parentFile.original_filename);
console.log('🔄 리비전 업로드 시작:', {
fileName: selectedFile.name,
jobNo: selectedProject.official_project_code || selectedProject.job_no,
parentFileId: parentFile.id,
bomName: parentFile.bom_name || parentFile.original_filename
});
const response = await api.post('/files/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: (progressEvent) => {
const progress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setUploadProgress(progress);
}
});
if (response.data.success) {
console.log('✅ 리비전 업로드 성공:', response.data);
// 성공 콜백 호출
if (onUploadSuccess) {
onUploadSuccess(response.data);
}
// 다이얼로그 닫기
handleClose();
} else {
throw new Error(response.data.message || '리비전 업로드 실패');
}
} catch (err) {
console.error('❌ 리비전 업로드 실패:', err);
setError(err.response?.data?.detail || err.message || '리비전 업로드에 실패했습니다.');
} finally {
setUploading(false);
setUploadProgress(0);
}
};
const handleClose = () => {
setSelectedFile(null);
setUploading(false);
setUploadProgress(0);
setError('');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
onClose();
};
if (!isOpen) return null;
return (
<div style={{
@@ -17,7 +113,7 @@ const RevisionUploadDialog = ({
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -25,53 +121,203 @@ const RevisionUploadDialog = ({
}}>
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
borderRadius: '16px',
padding: '32px',
width: '90%',
maxWidth: '500px',
width: '90%'
maxHeight: '90vh',
overflow: 'auto',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)'
}}>
<h3 style={{ margin: '0 0 16px 0' }}>
리비전 업로드: {revisionDialog.bomName}
</h3>
<input
type="file"
accept=".csv,.xlsx,.xls"
onChange={(e) => setRevisionFile(e.target.files[0])}
style={{
width: '100%',
marginBottom: '16px',
padding: '8px'
{/* 헤더 */}
<div style={{ marginBottom: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{
fontSize: '24px',
fontWeight: '700',
color: '#1f2937',
margin: 0
}}>
📝 New Revision Upload
</h2>
<button
onClick={handleClose}
style={{
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
color: '#6b7280',
padding: '4px'
}}
>
</button>
</div>
{/* 부모 파일 정보 */}
<div style={{
marginTop: '16px',
padding: '16px',
background: '#f8fafc',
borderRadius: '8px',
border: '1px solid #e2e8f0'
}}>
<div style={{ fontSize: '14px', color: '#64748b', marginBottom: '4px' }}>
Base BOM File:
</div>
<div style={{ fontSize: '16px', fontWeight: '600', color: '#1e293b' }}>
{parentFile?.bom_name || parentFile?.original_filename}
</div>
<div style={{ fontSize: '12px', color: '#64748b', marginTop: '4px' }}>
Current Revision: {parentFile?.revision || 'Rev.0'}
</div>
</div>
</div>
{/* 파일 선택 */}
<div style={{ marginBottom: '24px' }}>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
marginBottom: '8px'
}}>
Select New BOM File
</label>
<div style={{
border: '2px dashed #d1d5db',
borderRadius: '8px',
padding: '24px',
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
backgroundColor: selectedFile ? '#f0fdf4' : '#fafafa'
}}
/>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
{selectedFile ? (
<div>
<div style={{ fontSize: '24px', marginBottom: '8px' }}>📄</div>
<div style={{ fontSize: '16px', fontWeight: '600', color: '#059669' }}>
{selectedFile.name}
</div>
<div style={{ fontSize: '12px', color: '#6b7280', marginTop: '4px' }}>
{Math.round(selectedFile.size / 1024)} KB
</div>
</div>
) : (
<div>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📁</div>
<div style={{ fontSize: '16px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
Click to select file
</div>
<div style={{ fontSize: '14px', color: '#6b7280' }}>
Excel (.xlsx, .xls) or CSV files only
</div>
</div>
)}
</div>
</div>
{/* 업로드 진행률 */}
{uploading && (
<div style={{ marginBottom: '24px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px'
}}>
<span style={{ fontSize: '14px', fontWeight: '600', color: '#374151' }}>
Uploading...
</span>
<span style={{ fontSize: '14px', color: '#6b7280' }}>
{uploadProgress}%
</span>
</div>
<div style={{
width: '100%',
height: '8px',
backgroundColor: '#e5e7eb',
borderRadius: '4px',
overflow: 'hidden'
}}>
<div style={{
width: `${uploadProgress}%`,
height: '100%',
backgroundColor: '#3b82f6',
transition: 'width 0.3s ease'
}} />
</div>
</div>
)}
{/* 에러 메시지 */}
{error && (
<div style={{
marginBottom: '24px',
padding: '12px',
background: '#fee2e2',
border: '1px solid #fecaca',
borderRadius: '8px',
color: '#dc2626',
fontSize: '14px'
}}>
{error}
</div>
)}
{/* 버튼들 */}
<div style={{
display: 'flex',
gap: '12px',
justifyContent: 'flex-end'
}}>
<button
onClick={() => setRevisionDialog({ open: false, bomName: '', parentId: null })}
onClick={handleClose}
disabled={uploading}
style={{
padding: '8px 16px',
background: '#e2e8f0',
color: '#4a5568',
border: 'none',
borderRadius: '6px',
cursor: 'pointer'
padding: '12px 24px',
background: 'white',
color: '#374151',
border: '1px solid #d1d5db',
borderRadius: '8px',
cursor: uploading ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: '600',
opacity: uploading ? 0.5 : 1
}}
>
취소
Cancel
</button>
<button
onClick={handleRevisionUpload}
disabled={!revisionFile || uploading}
onClick={handleUpload}
disabled={!selectedFile || uploading}
style={{
padding: '8px 16px',
background: (!revisionFile || uploading) ? '#e2e8f0' : '#4299e1',
color: (!revisionFile || uploading) ? '#a0aec0' : 'white',
padding: '12px 24px',
background: (!selectedFile || uploading) ? '#9ca3af' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: (!revisionFile || uploading) ? 'not-allowed' : 'pointer'
borderRadius: '8px',
cursor: (!selectedFile || uploading) ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: '600',
transition: 'all 0.2s ease'
}}
>
{uploading ? '업로드 중...' : '업로드'}
{uploading ? 'Uploading...' : 'Upload Revision'}
</button>
</div>
</div>
@@ -79,26 +325,4 @@ const RevisionUploadDialog = ({
);
};
export default RevisionUploadDialog;
export default RevisionUploadDialog;

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import api from '../../../api';
import RevisionUploadDialog from '../../RevisionUploadDialog';
const BOMFilesTab = ({
selectedProject,
@@ -13,6 +14,7 @@ const BOMFilesTab = ({
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [groupedFiles, setGroupedFiles] = useState({});
const [revisionDialog, setRevisionDialog] = useState({ open: false, parentFile: null });
// BOM 파일 목록 로드
useEffect(() => {
@@ -99,10 +101,33 @@ const BOMFilesTab = ({
}
};
// 리비전 업로드 (향후 구현)
// 리비전 업로드
const handleRevisionUpload = (parentFile) => {
// TODO: 리비전 업로드 기능 구현
alert('리비전 업로드 기능은 향후 구현 예정입니다.');
console.log('🔄 리비전 업로드 시작:', parentFile);
setRevisionDialog({ open: true, parentFile });
};
// 리비전 업로드 성공 처리
const handleRevisionUploadSuccess = async (uploadResult) => {
console.log('✅ 리비전 업로드 성공:', uploadResult);
// BOM 파일 목록 새로고침
try {
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);
setGroupedFiles(groupFilesByBOM(files));
// 성공 메시지 표시 (선택사항)
console.log(`새 리비전 ${uploadResult.revision} 업로드 완료!`);
} catch (err) {
console.error('파일 목록 새로고침 실패:', err);
setError('파일 목록을 새로고침하는데 실패했습니다.');
}
};
// 날짜 포맷팅
@@ -422,6 +447,15 @@ const BOMFilesTab = ({
</div>
</div>
</div>
{/* 리비전 업로드 다이얼로그 */}
<RevisionUploadDialog
isOpen={revisionDialog.open}
onClose={() => setRevisionDialog({ open: false, parentFile: null })}
parentFile={revisionDialog.parentFile}
selectedProject={selectedProject}
onUploadSuccess={handleRevisionUploadSuccess}
/>
</div>
);
};

View File

@@ -4,5 +4,5 @@ export { default as ErrorMessage } from './ErrorMessage';
export { default as ConfirmDialog } from './ConfirmDialog';
// 기존 컴포넌트들도 re-export
export { default as UserMenu } from '../UserMenu';
export { default as UserMenu } from './UserMenu';
export { default as ErrorBoundary } from '../ErrorBoundary';

View File

@@ -1,314 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '../api';
/**
* 리비전 로직 처리 훅
* 구매 상태별 자재 처리 로직
*/
export const useRevisionLogic = (jobNo, currentFileId, previousFileId = null) => {
const [processingResult, setProcessingResult] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const processRevision = useCallback(async () => {
if (!jobNo || !currentFileId) return;
try {
setLoading(true);
setError(null);
const params = new URLSearchParams({
job_no: jobNo,
file_id: currentFileId
});
if (previousFileId) {
params.append('previous_file_id', previousFileId);
}
const response = await api.post(`/revision-redirect/process-revision/${jobNo}/${currentFileId}?${params}`);
if (response.data.success) {
setProcessingResult(response.data.data);
}
} catch (err) {
console.error('리비전 처리 실패:', err);
setError(err.message);
} finally {
setLoading(false);
}
}, [jobNo, currentFileId, previousFileId]);
const applyProcessingResults = useCallback(async (results) => {
try {
setLoading(true);
setError(null);
const response = await api.post('/revision-material/apply-results', {
processing_results: results
});
if (response.data.success) {
return response.data;
} else {
throw new Error(response.data.message || '처리 결과 적용 실패');
}
} catch (err) {
console.error('처리 결과 적용 실패:', err);
setError(err.message);
throw err;
} finally {
setLoading(false);
}
}, []);
return {
processingResult,
loading,
error,
processRevision,
applyProcessingResults,
setError
};
};
/**
* 카테고리별 자재 처리 훅
*/
export const useCategoryMaterialProcessing = (fileId, category) => {
const [materials, setMaterials] = useState([]);
const [processingInfo, setProcessingInfo] = useState({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const loadCategoryMaterials = useCallback(async () => {
if (!fileId || !category || category === 'PIPE') return;
try {
setLoading(true);
setError(null);
const response = await api.get(`/revision-material/category/${fileId}/${category}`);
if (response.data.success) {
setMaterials(response.data.data.materials || []);
setProcessingInfo(response.data.data.processing_info || {});
}
} catch (err) {
console.error('카테고리 자재 조회 실패:', err);
setError(err.message);
} finally {
setLoading(false);
}
}, [fileId, category]);
const processMaterial = useCallback(async (materialId, action, additionalData = {}) => {
try {
const response = await api.post(`/revision-material/process/${materialId}`, {
action,
...additionalData
});
if (response.data.success) {
// 자재 목록 새로고침
await loadCategoryMaterials();
return response.data;
} else {
throw new Error(response.data.message || '자재 처리 실패');
}
} catch (err) {
console.error('자재 처리 실패:', err);
setError(err.message);
throw err;
}
}, [loadCategoryMaterials]);
const updateMaterialStatus = useCallback((materialId, newStatus, additionalInfo = {}) => {
setMaterials(prev =>
prev.map(material =>
material.id === materialId
? {
...material,
revision_status: newStatus,
processing_info: {
...material.processing_info,
...additionalInfo
}
}
: material
)
);
}, []);
useEffect(() => {
loadCategoryMaterials();
}, [loadCategoryMaterials]);
return {
materials,
processingInfo,
loading,
error,
loadCategoryMaterials,
processMaterial,
updateMaterialStatus,
setError
};
};
/**
* 자재 선택 및 일괄 처리 훅
*/
export const useMaterialSelection = (materials = []) => {
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
const [selectAll, setSelectAll] = useState(false);
const toggleMaterial = useCallback((materialId) => {
setSelectedMaterials(prev => {
const newSet = new Set(prev);
if (newSet.has(materialId)) {
newSet.delete(materialId);
} else {
newSet.add(materialId);
}
return newSet;
});
}, []);
const toggleSelectAll = useCallback(() => {
if (selectAll) {
setSelectedMaterials(new Set());
} else {
const selectableMaterials = materials
.filter(material => material.processing_info?.action !== '완료')
.map(material => material.id);
setSelectedMaterials(new Set(selectableMaterials));
}
setSelectAll(!selectAll);
}, [selectAll, materials]);
const clearSelection = useCallback(() => {
setSelectedMaterials(new Set());
setSelectAll(false);
}, []);
const getSelectedMaterials = useCallback(() => {
return materials.filter(material => selectedMaterials.has(material.id));
}, [materials, selectedMaterials]);
const getSelectionSummary = useCallback(() => {
const selected = getSelectedMaterials();
const byStatus = selected.reduce((acc, material) => {
const status = material.processing_info?.display_status || 'UNKNOWN';
acc[status] = (acc[status] || 0) + 1;
return acc;
}, {});
return {
total: selected.length,
byStatus,
canProcess: selected.length > 0
};
}, [getSelectedMaterials]);
// materials 변경 시 selectAll 상태 업데이트
useEffect(() => {
const selectableMaterials = materials
.filter(material => material.processing_info?.action !== '완료');
if (selectableMaterials.length === 0) {
setSelectAll(false);
} else {
const allSelected = selectableMaterials.every(material =>
selectedMaterials.has(material.id)
);
setSelectAll(allSelected);
}
}, [materials, selectedMaterials]);
return {
selectedMaterials,
selectAll,
toggleMaterial,
toggleSelectAll,
clearSelection,
getSelectedMaterials,
getSelectionSummary
};
};
/**
* 리비전 처리 상태 추적 훅
*/
export const useRevisionProcessingStatus = (jobNo, fileId) => {
const [status, setStatus] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const loadStatus = useCallback(async () => {
if (!jobNo || !fileId) return;
try {
setLoading(true);
setError(null);
const response = await api.get(`/revision-status/${jobNo}/${fileId}`);
if (response.data.success) {
setStatus(response.data.data);
}
} catch (err) {
console.error('리비전 상태 조회 실패:', err);
setError(err.message);
} finally {
setLoading(false);
}
}, [jobNo, fileId]);
const updateProcessingProgress = useCallback((category, processed, total) => {
setStatus(prev => {
if (!prev) return prev;
const newCategoryStatus = {
...prev.processing_status.category_breakdown[category],
processed,
pending: total - processed
};
const newCategoryBreakdown = {
...prev.processing_status.category_breakdown,
[category]: newCategoryStatus
};
// 전체 통계 재계산
const totalProcessed = Object.values(newCategoryBreakdown)
.reduce((sum, cat) => sum + cat.processed, 0);
const totalMaterials = Object.values(newCategoryBreakdown)
.reduce((sum, cat) => sum + cat.total, 0);
return {
...prev,
processing_status: {
...prev.processing_status,
total_processed: totalProcessed,
pending_processing: totalMaterials - totalProcessed,
completion_percentage: totalMaterials > 0 ? (totalProcessed / totalMaterials * 100) : 0,
category_breakdown: newCategoryBreakdown
}
};
});
}, []);
useEffect(() => {
loadStatus();
}, [loadStatus]);
return {
status,
loading,
error,
loadStatus,
updateProcessingProgress,
setError
};
};

View File

@@ -1,108 +0,0 @@
import { useState, useEffect } from 'react';
import { api } from '../api';
/**
* 리비전 리다이렉트 훅
* BOM 페이지 접근 시 리비전 페이지로 리다이렉트 필요성 확인
*/
export const useRevisionRedirect = (jobNo, fileId, previousFileId = null) => {
const [redirectInfo, setRedirectInfo] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!jobNo || !fileId) {
setLoading(false);
return;
}
checkRevisionRedirect();
}, [jobNo, fileId, previousFileId]);
const checkRevisionRedirect = async () => {
try {
setLoading(true);
setError(null);
const params = new URLSearchParams({
job_no: jobNo,
file_id: fileId
});
if (previousFileId) {
params.append('previous_file_id', previousFileId);
}
const response = await api.get(`/revision-redirect/check/${jobNo}/${fileId}?${params}`);
if (response.data.success) {
setRedirectInfo(response.data.data);
}
} catch (err) {
console.error('리비전 리다이렉트 확인 실패:', err);
setError(err.message);
// 에러 발생 시 기존 BOM 페이지 사용
setRedirectInfo({
should_redirect: false,
reason: '리비전 상태 확인 실패 - 기존 페이지 사용',
redirect_url: null,
processing_summary: null
});
} finally {
setLoading(false);
}
};
return {
redirectInfo,
loading,
error,
refetch: checkRevisionRedirect
};
};
/**
* 리비전 처리 로직 훅
* 리비전 페이지에서 사용할 상세 처리 결과 조회
*/
export const useRevisionProcessing = (jobNo, fileId, previousFileId = null) => {
const [processingResult, setProcessingResult] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const processRevision = async () => {
if (!jobNo || !fileId) return;
try {
setLoading(true);
setError(null);
const params = new URLSearchParams({
job_no: jobNo,
file_id: fileId
});
if (previousFileId) {
params.append('previous_file_id', previousFileId);
}
const response = await api.post(`/revision-redirect/process-revision/${jobNo}/${fileId}?${params}`);
if (response.data.success) {
setProcessingResult(response.data.data);
}
} catch (err) {
console.error('리비전 처리 실패:', err);
setError(err.message);
} finally {
setLoading(false);
}
};
return {
processingResult,
loading,
error,
processRevision
};
};

View File

@@ -34,6 +34,12 @@ const BOMManagementPage = ({
const [userRequirements, setUserRequirements] = useState({});
const [purchasedMaterials, setPurchasedMaterials] = useState(new Set());
const [error, setError] = useState(null);
// 리비전 관련 상태
const [isRevisionMode, setIsRevisionMode] = useState(false);
const [revisionData, setRevisionData] = useState(null);
const [previousFileId, setPreviousFileId] = useState(null);
const [changedMaterials, setChangedMaterials] = useState({});
// 자재 업데이트 함수 (브랜드, 사용자 요구사항 등)
const updateMaterial = (materialId, updates) => {
@@ -155,12 +161,58 @@ const BOMManagementPage = ({
}
};
// 리비전 모드 감지 및 변경된 자재 로드
const checkAndLoadRevisionData = async () => {
try {
// 현재 job_no의 모든 파일 목록 확인
const filesResponse = await api.get(`/files/list?job_no=${jobNo}`);
const files = filesResponse.data.files || [];
if (files.length > 1) {
// 파일이 여러 개 있으면 리비전 모드 활성화
setIsRevisionMode(true);
// 파일들을 업로드 날짜순으로 정렬
const sortedFiles = files.sort((a, b) => new Date(a.upload_date) - new Date(b.upload_date));
// 이전 파일 ID 찾기 (현재 파일 이전 버전)
const currentIndex = sortedFiles.findIndex(file => file.id === parseInt(fileId));
if (currentIndex > 0) {
const previousFile = sortedFiles[currentIndex - 1];
setPreviousFileId(previousFile.id);
// 변경된 자재 로드
await loadChangedMaterials(fileId, previousFile.id);
}
}
} catch (error) {
console.error('리비전 데이터 로드 실패:', error);
// API 오류 시 리비전 모드 비활성화
setIsRevisionMode(false);
}
};
// 변경된 자재 로드
const loadChangedMaterials = async (currentFileId, previousFileId) => {
try {
const response = await api.get(`/simple-revision/changed-materials/${currentFileId}/${previousFileId}`);
if (response.data.success) {
setChangedMaterials(response.data.data.changes_by_category || {});
setRevisionData(response.data.data);
console.log('✅ 변경된 자재 로드 완료:', response.data.data);
}
} catch (error) {
console.error('변경된 자재 로드 실패:', error);
}
};
// 초기 로드
useEffect(() => {
if (fileId) {
loadMaterials(fileId);
loadAvailableRevisions();
loadUserRequirements(fileId);
checkAndLoadRevisionData(); // 리비전 데이터 확인
}
}, [fileId]);
@@ -180,12 +232,30 @@ const BOMManagementPage = ({
}
}, [materials, selectedCategory]);
// 카테고리별 자재 필터링
// 카테고리별 자재 필터링 (리비전 모드 지원)
const getCategoryMaterials = (category) => {
return materials.filter(material =>
material.classified_category === category ||
material.category === category
);
if (isRevisionMode && changedMaterials[category]) {
// 리비전 모드: 변경된 자재만 표시
const changedMaterialIds = changedMaterials[category].changes.map(change => change.material_id);
return materials.filter(material =>
(material.classified_category === category || material.category === category) &&
changedMaterialIds.includes(material.id)
);
} else {
// 일반 모드: 모든 자재 표시
return materials.filter(material =>
material.classified_category === category ||
material.category === category
);
}
};
// 리비전 액션 정보 가져오기
const getRevisionAction = (materialId, category) => {
if (!isRevisionMode || !changedMaterials[category]) return null;
const change = changedMaterials[category].changes.find(c => c.material_id === materialId);
return change || null;
};
// 카테고리별 컴포넌트 렌더링
@@ -210,7 +280,11 @@ const BOMManagementPage = ({
fileId,
jobNo,
user,
onNavigate
onNavigate,
// 리비전 관련 props 추가
isRevisionMode,
getRevisionAction: (materialId) => getRevisionAction(materialId, selectedCategory),
revisionData
};
switch (selectedCategory) {
@@ -282,15 +356,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',
@@ -298,6 +389,15 @@ const BOMManagementPage = ({
fontWeight: '400'
}}>
{bomName} - {currentRevision} | Project: {selectedProject?.job_name || jobNo}
{isRevisionMode && revisionData && (
<span style={{
marginLeft: '16px',
color: '#f59e0b',
fontWeight: '600'
}}>
{revisionData.total_changed_materials} materials changed
</span>
)}
</p>
</div>
<button
@@ -415,6 +515,7 @@ const BOMManagementPage = ({
.map((category) => {
const isActive = selectedCategory === category.key;
const count = getCategoryMaterials(category.key).length;
const hasChanges = isRevisionMode && changedMaterials[category.key];
return (
<button
@@ -423,9 +524,11 @@ const BOMManagementPage = ({
style={{
background: isActive
? `linear-gradient(135deg, ${category.color} 0%, ${category.color}dd 100%)`
: 'white',
color: isActive ? 'white' : '#64748b',
border: isActive ? 'none' : '1px solid #e2e8f0',
: hasChanges
? 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)'
: 'white',
color: isActive ? 'white' : hasChanges ? '#92400e' : '#64748b',
border: isActive ? 'none' : hasChanges ? '2px solid #f59e0b' : '1px solid #e2e8f0',
borderRadius: '12px',
padding: '16px 12px',
cursor: 'pointer',
@@ -433,7 +536,8 @@ const BOMManagementPage = ({
fontWeight: '600',
transition: 'all 0.2s ease',
textAlign: 'center',
boxShadow: isActive ? `0 4px 14px 0 ${category.color}39` : '0 2px 8px rgba(0,0,0,0.05)'
boxShadow: isActive ? `0 4px 14px 0 ${category.color}39` : hasChanges ? '0 4px 14px 0 rgba(245, 158, 11, 0.3)' : '0 2px 8px rgba(0,0,0,0.05)',
position: 'relative'
}}
onMouseEnter={(e) => {
if (!isActive) {
@@ -448,8 +552,16 @@ const BOMManagementPage = ({
}
}}
>
<div style={{ marginBottom: '4px' }}>
<div style={{ marginBottom: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '4px' }}>
{category.label}
{hasChanges && (
<div style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: isActive ? 'rgba(255,255,255,0.8)' : '#f59e0b'
}}></div>
)}
</div>
<div style={{
fontSize: '12px',
@@ -457,6 +569,11 @@ const BOMManagementPage = ({
fontWeight: '500'
}}>
{count} items
{hasChanges && (
<span style={{ marginLeft: '4px', fontSize: '10px' }}>
({changedMaterials[category.key].changed_count} changed)
</span>
)}
</div>
</button>
);

View File

@@ -1,815 +0,0 @@
/* Enhanced Revision Page - 기존 스타일 통일 */
* {
box-sizing: border-box;
}
.materials-page {
background: #f8f9fa;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif;
overflow-x: auto;
min-width: 1400px;
}
/* 헤더 */
.materials-header {
background: white;
border-bottom: 1px solid #e5e7eb;
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #6366f1;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.back-button:hover {
background: #5558e3;
transform: translateY(-1px);
}
.header-center {
display: flex;
align-items: center;
gap: 16px;
}
/* 메인 콘텐츠 */
.materials-content {
padding: 24px;
}
/* 컨트롤 섹션 */
.control-section {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
border: 1px solid #e5e7eb;
}
.section-header h3 {
margin: 0 0 16px 0;
color: #1f2937;
font-size: 18px;
font-weight: 600;
}
.control-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
align-items: end;
}
.control-group {
display: flex;
flex-direction: column;
}
.control-group label {
font-weight: 600;
color: #34495e;
margin-bottom: 8px;
font-size: 0.95em;
}
.control-group select {
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
background: white;
color: #374151;
transition: all 0.2s ease;
}
.control-group select:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.control-group select:disabled {
background-color: #f9fafb;
color: #9ca3af;
cursor: not-allowed;
}
.btn-compare {
padding: 8px 16px;
background: #6366f1;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.btn-compare:hover:not(:disabled) {
background: #5558e3;
transform: translateY(-1px);
}
.btn-compare:disabled {
background: #9ca3af;
cursor: not-allowed;
transform: none;
}
/* 메인 콘텐츠 레이아웃 */
.revision-content {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 25px;
}
.content-left, .content-right {
display: flex;
flex-direction: column;
gap: 25px;
}
/* 비교 결과 */
.comparison-result {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
border: 1px solid #e5e7eb;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid #e5e7eb;
}
.result-header h3 {
margin: 0;
color: #1f2937;
font-size: 18px;
font-weight: 600;
}
.btn-apply {
padding: 8px 16px;
background: #10b981;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-apply:hover:not(:disabled) {
background: #059669;
transform: translateY(-1px);
}
/* 비교 요약 */
.comparison-summary {
margin-bottom: 30px;
}
.comparison-summary h3 {
margin: 0 0 20px 0;
color: #2c3e50;
font-size: 1.2em;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.summary-card {
padding: 20px;
border-radius: 10px;
border-left: 4px solid;
}
.summary-card.purchased {
background: #e8f5e8;
border-left-color: #27ae60;
}
.summary-card.unpurchased {
background: #fff3cd;
border-left-color: #ffc107;
}
.summary-card.changes {
background: #e3f2fd;
border-left-color: #2196f3;
}
.summary-card h4 {
margin: 0 0 15px 0;
font-size: 1.1em;
color: #2c3e50;
}
.summary-stats {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.stat-item {
padding: 6px 12px;
border-radius: 20px;
font-size: 0.9em;
font-weight: 600;
background: white;
color: #2c3e50;
border: 1px solid #e1e8ed;
}
.stat-item.increase {
background: #ffebee;
color: #c62828;
border-color: #ffcdd2;
}
.stat-item.decrease {
background: #e8f5e8;
color: #2e7d32;
border-color: #c8e6c9;
}
.stat-item.new {
background: #e3f2fd;
color: #1565c0;
border-color: #bbdefb;
}
.stat-item.deleted {
background: #fce4ec;
color: #ad1457;
border-color: #f8bbd9;
}
/* 변경사항 상세 */
.change-details {
margin-top: 20px;
}
.change-details h3 {
margin: 0 0 20px 0;
color: #2c3e50;
font-size: 1.2em;
}
.change-section {
margin-bottom: 25px;
padding: 20px;
background: #f8f9fa;
border-radius: 10px;
}
.change-section h4 {
margin: 0 0 15px 0;
color: #2c3e50;
font-size: 1.1em;
}
.change-category {
margin-bottom: 20px;
}
.change-category h5 {
margin: 0 0 12px 0;
color: #34495e;
font-size: 1em;
}
.material-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.material-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: white;
border-radius: 8px;
border-left: 3px solid #e1e8ed;
}
.change-category.additional-purchase .material-item {
border-left-color: #e74c3c;
}
.change-category.excess-inventory .material-item {
border-left-color: #f39c12;
}
.change-category.quantity-updated .material-item {
border-left-color: #3498db;
}
.change-category.quantity-reduced .material-item {
border-left-color: #95a5a6;
}
.change-category.new-materials .material-item {
border-left-color: #27ae60;
}
.change-category.deleted-materials .material-item {
border-left-color: #e74c3c;
}
.material-desc {
flex: 1;
font-weight: 500;
color: #2c3e50;
}
.quantity-change, .quantity-info {
font-weight: 600;
color: #7f8c8d;
font-size: 0.9em;
}
.reason {
font-style: italic;
color: #95a5a6;
font-size: 0.85em;
}
/* PIPE 길이 요약 */
.pipe-length-summary {
background: white;
border-radius: 12px;
padding: 25px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.summary-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #ecf0f1;
}
.summary-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.3em;
}
.btn-recalculate {
padding: 8px 16px;
background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 0.9em;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-recalculate:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(243, 156, 18, 0.3);
}
.pipe-stats {
display: flex;
gap: 20px;
margin-bottom: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
}
.pipe-stats span {
font-weight: 600;
color: #2c3e50;
}
.pipe-lines {
display: flex;
flex-direction: column;
gap: 12px;
}
.pipe-line {
padding: 15px;
border-radius: 8px;
border-left: 4px solid;
background: white;
transition: all 0.2s ease;
}
.pipe-line:hover {
transform: translateX(5px);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.pipe-line.purchased {
border-left-color: #27ae60;
background: #e8f5e8;
}
.pipe-line.pending {
border-left-color: #f39c12;
background: #fff3cd;
}
.pipe-line.mixed {
border-left-color: #e74c3c;
background: #ffebee;
}
.line-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.drawing-line {
font-weight: 600;
color: #2c3e50;
font-size: 1.05em;
}
.material-spec {
font-size: 0.9em;
color: #7f8c8d;
}
.line-stats {
display: flex;
gap: 15px;
align-items: center;
}
.line-stats span {
font-size: 0.9em;
color: #34495e;
}
.status {
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8em;
font-weight: 600;
}
.status.purchased {
background: #d4edda;
color: #155724;
}
.status.pending {
background: #fff3cd;
color: #856404;
}
.status.mixed {
background: #f8d7da;
color: #721c24;
}
/* 비교 이력 */
.comparison-history {
background: white;
border-radius: 12px;
padding: 25px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.comparison-history h3 {
margin: 0 0 20px 0;
color: #2c3e50;
font-size: 1.3em;
}
.history-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.history-item {
padding: 15px;
border-radius: 8px;
border: 2px solid;
transition: all 0.2s ease;
}
.history-item.applied {
border-color: #27ae60;
background: #e8f5e8;
}
.history-item.pending {
border-color: #f39c12;
background: #fff3cd;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.comparison-date {
font-weight: 600;
color: #2c3e50;
font-size: 0.9em;
}
.status.applied {
background: #d4edda;
color: #155724;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8em;
font-weight: 600;
}
.status.pending {
background: #fff3cd;
color: #856404;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8em;
font-weight: 600;
}
.history-summary {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.history-summary span {
font-size: 0.85em;
color: #7f8c8d;
}
.btn-apply-small {
padding: 6px 12px;
background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 0.8em;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
align-self: flex-start;
}
.btn-apply-small:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(39, 174, 96, 0.3);
}
.no-history {
text-align: center;
color: #95a5a6;
font-style: italic;
padding: 40px 20px;
}
/* 반응형 디자인 */
@media (max-width: 1200px) {
.revision-content {
grid-template-columns: 1fr;
}
.control-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
}
@media (max-width: 768px) {
.enhanced-revision-page {
padding: 15px;
}
.page-header {
padding: 15px;
}
.page-header h1 {
font-size: 1.8em;
}
.revision-controls,
.comparison-result,
.pipe-length-summary,
.comparison-history {
padding: 20px;
}
.control-grid {
grid-template-columns: 1fr;
}
.summary-grid {
grid-template-columns: 1fr;
}
.line-info {
flex-direction: column;
align-items: flex-start;
gap: 5px;
}
.line-stats {
flex-wrap: wrap;
gap: 10px;
}
.material-item {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
/* 카테고리별 자재 관리 섹션 */
.category-materials-section {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 24px;
margin-bottom: 24px;
border: 1px solid #e2e8f0;
}
.category-materials-section h3 {
margin: 0 0 20px 0;
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.category-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.category-card {
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 20px;
transition: all 0.3s ease;
position: relative;
}
.category-card:hover {
border-color: #6366f1;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.15);
transform: translateY(-2px);
}
.category-card.has-revisions {
background: linear-gradient(135deg, #fef3c7 0%, #fbbf24 100%);
border-color: #f59e0b;
}
.category-card.has-revisions:hover {
border-color: #d97706;
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.25);
}
.category-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.category-icon {
font-size: 24px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.category-info h4 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.category-desc {
font-size: 14px;
color: #64748b;
}
.category-stats {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.category-stats .stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 12px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
min-width: 60px;
}
.category-stats .stat-item.revision {
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: white;
}
.category-stats .stat-item.inventory {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
color: white;
}
.category-stats .stat-label {
font-size: 12px;
font-weight: 500;
margin-bottom: 2px;
}
.category-stats .stat-value {
font-size: 18px;
font-weight: 700;
}
.empty-category {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #9ca3af;
font-size: 14px;
font-style: italic;
}
/* 카테고리 카드 반응형 */
@media (max-width: 768px) {
.category-grid {
grid-template-columns: 1fr;
}
.category-stats {
justify-content: center;
}
}

View File

@@ -1,683 +0,0 @@
import React, { useState, useEffect } from 'react';
import { api } from '../api';
import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../components/common';
import FittingRevisionPage from './revision/FittingRevisionPage';
import FlangeRevisionPage from './revision/FlangeRevisionPage';
import SpecialRevisionPage from './revision/SpecialRevisionPage';
import SupportRevisionPage from './revision/SupportRevisionPage';
import UnclassifiedRevisionPage from './revision/UnclassifiedRevisionPage';
import ValveRevisionPage from './revision/ValveRevisionPage';
import GasketRevisionPage from './revision/GasketRevisionPage';
import BoltRevisionPage from './revision/BoltRevisionPage';
import PipeCuttingPlanPage from './revision/PipeCuttingPlanPage';
import './EnhancedRevisionPage.css';
const EnhancedRevisionPage = ({ onNavigate, user }) => {
const [jobs, setJobs] = useState([]);
const [selectedJob, setSelectedJob] = useState('');
const [files, setFiles] = useState([]);
const [currentFile, setCurrentFile] = useState('');
const [previousFile, setPreviousFile] = useState('');
const [comparisonResult, setComparisonResult] = useState(null);
const [comparisonHistory, setComparisonHistory] = useState([]);
const [pipeLengthSummary, setPipeLengthSummary] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [showApplyDialog, setShowApplyDialog] = useState(false);
const [selectedComparison, setSelectedComparison] = useState(null);
// 카테고리별 페이지 라우팅
const [selectedCategory, setSelectedCategory] = useState('');
const [categoryMaterials, setCategoryMaterials] = useState({});
// 작업 목록 조회
useEffect(() => {
fetchJobs();
}, []);
// 선택된 작업의 파일 목록 조회
useEffect(() => {
if (selectedJob) {
fetchJobFiles();
fetchComparisonHistory();
}
}, [selectedJob]);
// 현재 파일의 PIPE 길이 요약 및 카테고리별 자재 조회
useEffect(() => {
if (currentFile) {
fetchPipeLengthSummary();
fetchCategoryMaterials();
}
}, [currentFile]);
const fetchJobs = async () => {
try {
const response = await api.get('/dashboard/projects');
setJobs(response.data.projects || []);
} catch (err) {
setError('작업 목록 조회 실패: ' + err.message);
}
};
const fetchJobFiles = async () => {
try {
const response = await api.get(`/files/by-job/${selectedJob}`);
setFiles(response.data || []);
} catch (err) {
setError('파일 목록 조회 실패: ' + err.message);
}
};
const fetchComparisonHistory = async () => {
try {
const response = await api.get(`/enhanced-revision/comparison-history/${selectedJob}`);
setComparisonHistory(response.data.data || []);
} catch (err) {
console.error('비교 이력 조회 실패:', err);
}
};
const fetchPipeLengthSummary = async () => {
try {
const response = await api.get(`/enhanced-revision/pipe-length-summary/${currentFile}`);
setPipeLengthSummary(response.data.data);
} catch (err) {
console.error('PIPE 길이 요약 조회 실패:', err);
}
};
const fetchCategoryMaterials = async () => {
if (!currentFile) return;
try {
const categories = ['PIPE', 'FITTING', 'FLANGE', 'VALVE', 'GASKET', 'BOLT', 'SUPPORT', 'SPECIAL', 'UNCLASSIFIED'];
const materialStats = {};
for (const category of categories) {
try {
const response = await api.get(`/revision-material/category/${currentFile}/${category}`);
materialStats[category] = {
count: response.data.data?.materials?.length || 0,
processing_info: response.data.data?.processing_info || {}
};
} catch (err) {
console.error(`Failed to fetch ${category} materials:`, err);
materialStats[category] = { count: 0, processing_info: {} };
}
}
setCategoryMaterials(materialStats);
} catch (err) {
console.error('카테고리별 자재 조회 실패:', err);
}
};
const handleCompareRevisions = async () => {
if (!selectedJob || !currentFile) {
setError('작업과 현재 파일을 선택해주세요.');
return;
}
setLoading(true);
setError('');
try {
const params = {
job_no: selectedJob,
current_file_id: parseInt(currentFile),
save_comparison: true
};
if (previousFile) {
params.previous_file_id = parseInt(previousFile);
}
const response = await api.post('/enhanced-revision/compare-revisions', null, { params });
setComparisonResult(response.data.data);
// 비교 이력 새로고침
fetchComparisonHistory();
} catch (err) {
setError('리비전 비교 실패: ' + err.message);
} finally {
setLoading(false);
}
};
const handleApplyChanges = async (comparisonId) => {
setLoading(true);
setError('');
try {
const response = await api.post(`/enhanced-revision/apply-revision-changes/${comparisonId}`);
if (response.data.success) {
alert('리비전 변경사항이 성공적으로 적용되었습니다.');
fetchComparisonHistory();
setComparisonResult(null);
}
} catch (err) {
setError('변경사항 적용 실패: ' + err.message);
} finally {
setLoading(false);
setShowApplyDialog(false);
}
};
const handleRecalculatePipeLengths = async () => {
if (!currentFile) return;
setLoading(true);
try {
const response = await api.post(`/enhanced-revision/recalculate-pipe-lengths/${currentFile}`);
if (response.data.success) {
alert(`PIPE 자재 ${response.data.data.updated_count}개의 길이를 재계산했습니다.`);
fetchPipeLengthSummary();
}
} catch (err) {
setError('PIPE 길이 재계산 실패: ' + err.message);
} finally {
setLoading(false);
}
};
const renderComparisonSummary = (summary) => {
if (!summary) return null;
return (
<div className="comparison-summary">
<h3>📊 비교 요약</h3>
<div className="summary-grid">
<div className="summary-card purchased">
<h4>🛒 구매 완료 자재</h4>
<div className="summary-stats">
<span className="stat-item">유지: {summary.purchased_maintained}</span>
<span className="stat-item increase">추가구매: {summary.purchased_increased}</span>
<span className="stat-item decrease">잉여재고: {summary.purchased_decreased}</span>
</div>
</div>
<div className="summary-card unpurchased">
<h4>📋 구매 미완료 자재</h4>
<div className="summary-stats">
<span className="stat-item">유지: {summary.unpurchased_maintained}</span>
<span className="stat-item increase">수량증가: {summary.unpurchased_increased}</span>
<span className="stat-item decrease">수량감소: {summary.unpurchased_decreased}</span>
</div>
</div>
<div className="summary-card changes">
<h4>🔄 변경사항</h4>
<div className="summary-stats">
<span className="stat-item new">신규: {summary.new_materials}</span>
<span className="stat-item deleted">삭제: {summary.deleted_materials}</span>
</div>
</div>
</div>
</div>
);
};
const renderChangeDetails = (changes) => {
if (!changes) return null;
return (
<div className="change-details">
<h3>📋 상세 변경사항</h3>
{/* 구매 완료 자재 변경사항 */}
{changes.purchased_materials && (
<div className="change-section">
<h4>🛒 구매 완료 자재</h4>
{changes.purchased_materials.additional_purchase_needed?.length > 0 && (
<div className="change-category additional-purchase">
<h5>📈 추가 구매 필요</h5>
<div className="material-list">
{changes.purchased_materials.additional_purchase_needed.map((item, idx) => (
<div key={idx} className="material-item">
<span className="material-desc">{item.material.original_description}</span>
<span className="quantity-change">
{item.previous_quantity} {item.current_quantity}
(+{item.additional_needed})
</span>
</div>
))}
</div>
</div>
)}
{changes.purchased_materials.excess_inventory?.length > 0 && (
<div className="change-category excess-inventory">
<h5>📉 잉여 재고</h5>
<div className="material-list">
{changes.purchased_materials.excess_inventory.map((item, idx) => (
<div key={idx} className="material-item">
<span className="material-desc">{item.material.original_description}</span>
<span className="quantity-change">
{item.previous_quantity} {item.current_quantity}
(-{item.excess_quantity})
</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* 구매 미완료 자재 변경사항 */}
{changes.unpurchased_materials && (
<div className="change-section">
<h4>📋 구매 미완료 자재</h4>
{changes.unpurchased_materials.quantity_updated?.length > 0 && (
<div className="change-category quantity-updated">
<h5>📊 수량 변경</h5>
<div className="material-list">
{changes.unpurchased_materials.quantity_updated.map((item, idx) => (
<div key={idx} className="material-item">
<span className="material-desc">{item.material.original_description}</span>
<span className="quantity-change">
{item.previous_quantity} {item.current_quantity}
</span>
</div>
))}
</div>
</div>
)}
{changes.unpurchased_materials.quantity_reduced?.length > 0 && (
<div className="change-category quantity-reduced">
<h5>📉 수량 감소</h5>
<div className="material-list">
{changes.unpurchased_materials.quantity_reduced.map((item, idx) => (
<div key={idx} className="material-item">
<span className="material-desc">{item.material.original_description}</span>
<span className="quantity-change">
{item.previous_quantity} {item.current_quantity}
</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* 신규/삭제 자재 */}
{(changes.new_materials?.length > 0 || changes.deleted_materials?.length > 0) && (
<div className="change-section">
<h4>🔄 신규/삭제 자재</h4>
{changes.new_materials?.length > 0 && (
<div className="change-category new-materials">
<h5> 신규 자재</h5>
<div className="material-list">
{changes.new_materials.map((item, idx) => (
<div key={idx} className="material-item">
<span className="material-desc">{item.material.original_description}</span>
<span className="quantity-info">수량: {item.material.quantity}</span>
</div>
))}
</div>
</div>
)}
{changes.deleted_materials?.length > 0 && (
<div className="change-category deleted-materials">
<h5> 삭제된 자재</h5>
<div className="material-list">
{changes.deleted_materials.map((item, idx) => (
<div key={idx} className="material-item">
<span className="material-desc">{item.material.original_description}</span>
<span className="reason">{item.reason}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
};
const renderPipeLengthSummary = () => {
if (!pipeLengthSummary) return null;
return (
<div className="pipe-length-summary">
<div className="summary-header">
<h3>🔧 PIPE 자재 길이 요약</h3>
<button
className="btn-recalculate"
onClick={handleRecalculatePipeLengths}
disabled={loading}
>
🔄 길이 재계산
</button>
</div>
<div className="pipe-stats">
<span> 라인: {pipeLengthSummary.total_lines}</span>
<span> 길이: {pipeLengthSummary.total_length?.toFixed(2)}m</span>
</div>
<div className="pipe-lines">
{pipeLengthSummary.pipe_lines?.map((line, idx) => (
<div key={idx} className={`pipe-line ${line.purchase_status}`}>
<div className="line-info">
<span className="drawing-line">
{line.drawing_name} - {line.line_no}
</span>
<span className="material-spec">
{line.material_grade} {line.schedule} {line.nominal_size}
</span>
</div>
<div className="line-stats">
<span className="length">길이: {line.total_length?.toFixed(2)}m</span>
<span className="segments">구간: {line.segment_count}</span>
<span className={`status ${line.purchase_status}`}>
{line.purchase_status === 'purchased' ? '구매완료' :
line.purchase_status === 'pending' ? '구매대기' : '혼재'}
</span>
</div>
</div>
))}
</div>
</div>
);
};
// 카테고리별 페이지 렌더링
if (selectedCategory && currentFile && previousFile) {
const categoryProps = {
jobNo: selectedJob,
fileId: parseInt(currentFile),
previousFileId: parseInt(previousFile),
onNavigate: (page) => {
if (page === 'enhanced-revision') {
setSelectedCategory('');
} else {
onNavigate(page);
}
},
user
};
switch (selectedCategory) {
case 'FITTING':
return <FittingRevisionPage {...categoryProps} />;
case 'FLANGE':
return <FlangeRevisionPage {...categoryProps} />;
case 'SPECIAL':
return <SpecialRevisionPage {...categoryProps} />;
case 'SUPPORT':
return <SupportRevisionPage {...categoryProps} />;
case 'UNCLASSIFIED':
return <UnclassifiedRevisionPage {...categoryProps} />;
case 'VALVE':
return <ValveRevisionPage {...categoryProps} />;
case 'GASKET':
return <GasketRevisionPage {...categoryProps} />;
case 'BOLT':
return <BoltRevisionPage {...categoryProps} />;
case 'PIPE':
return <PipeCuttingPlanPage {...categoryProps} />;
default:
setSelectedCategory('');
break;
}
}
return (
<div className="materials-page">
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate ? onNavigate('dashboard') : window.history.back()}
>
뒤로가기
</button>
<div className="header-center">
<h1 style={{ margin: 0, fontSize: '24px', fontWeight: '600', color: '#1f2937' }}>
🔄 강화된 리비전 관리
</h1>
<span style={{ color: '#6b7280', fontSize: '14px' }}>
구매 상태를 고려한 스마트 리비전 비교
</span>
</div>
</div>
</div>
{error && <ErrorMessage message={error} onClose={() => setError('')} />}
{/* 메인 콘텐츠 */}
<div className="materials-content">
<div className="control-section">
<div className="section-header">
<h3>📂 비교 설정</h3>
</div>
<div className="control-grid">
<div className="control-group">
<label>작업 선택:</label>
<select
value={selectedJob}
onChange={(e) => setSelectedJob(e.target.value)}
disabled={loading}
>
<option value="">작업을 선택하세요</option>
{jobs.map(job => (
<option key={job.job_no} value={job.job_no}>
{job.job_no} - {job.job_name}
</option>
))}
</select>
</div>
<div className="control-group">
<label>현재 파일:</label>
<select
value={currentFile}
onChange={(e) => setCurrentFile(e.target.value)}
disabled={loading || !selectedJob}
>
<option value="">현재 파일을 선택하세요</option>
{files.map(file => (
<option key={file.id} value={file.id}>
{file.original_filename} ({file.revision})
</option>
))}
</select>
</div>
<div className="control-group">
<label>이전 파일 (선택사항):</label>
<select
value={previousFile}
onChange={(e) => setPreviousFile(e.target.value)}
disabled={loading || !selectedJob}
>
<option value="">자동 탐지</option>
{files.filter(f => f.id !== parseInt(currentFile)).map(file => (
<option key={file.id} value={file.id}>
{file.original_filename} ({file.revision})
</option>
))}
</select>
</div>
<div className="control-group">
<button
className="btn-compare"
onClick={handleCompareRevisions}
disabled={loading || !selectedJob || !currentFile}
>
{loading ? <LoadingSpinner size="small" /> : '🔍 리비전 비교'}
</button>
</div>
</div>
</div>
</div>
<div className="revision-content">
<div className="content-left">
{/* 비교 결과 */}
{comparisonResult && (
<div className="comparison-result">
<div className="result-header">
<h3>📊 비교 결과</h3>
{comparisonResult.comparison_id && (
<button
className="btn-apply"
onClick={() => {
setSelectedComparison(comparisonResult.comparison_id);
setShowApplyDialog(true);
}}
disabled={loading}
>
변경사항 적용
</button>
)}
</div>
{renderComparisonSummary(comparisonResult.summary)}
{renderChangeDetails(comparisonResult.changes)}
</div>
)}
{/* PIPE 길이 요약 */}
{renderPipeLengthSummary()}
{/* 카테고리별 자재 관리 */}
{currentFile && previousFile && Object.keys(categoryMaterials).length > 0 && (
<div className="category-materials-section">
<h3>📂 카테고리별 리비전 관리</h3>
<div className="category-grid">
{[
{ key: 'PIPE', name: 'PIPE', icon: '🔧', description: 'Cutting Plan 관리' },
{ key: 'FITTING', name: 'FITTING', icon: '🔧', description: '피팅 자재' },
{ key: 'FLANGE', name: 'FLANGE', icon: '🔩', description: '플랜지 자재' },
{ key: 'VALVE', name: 'VALVE', icon: '🚰', description: '밸브 자재' },
{ key: 'GASKET', name: 'GASKET', icon: '⭕', description: '가스켓 자재' },
{ key: 'BOLT', name: 'BOLT', icon: '🔩', description: '볼트 자재' },
{ key: 'SUPPORT', name: 'SUPPORT', icon: '🏗️', description: '지지대 자재' },
{ key: 'SPECIAL', name: 'SPECIAL', icon: '⭐', description: '특수 자재' },
{ key: 'UNCLASSIFIED', name: 'UNCLASSIFIED', icon: '❓', description: '미분류 자재' }
].map(category => {
const stats = categoryMaterials[category.key] || { count: 0, processing_info: {} };
const hasRevisionMaterials = stats.processing_info?.by_status?.REVISION_MATERIAL > 0;
return (
<div
key={category.key}
className={`category-card ${hasRevisionMaterials ? 'has-revisions' : ''}`}
onClick={() => stats.count > 0 && setSelectedCategory(category.key)}
style={{ cursor: stats.count > 0 ? 'pointer' : 'not-allowed' }}
>
<div className="category-header">
<span className="category-icon">{category.icon}</span>
<div className="category-info">
<h4>{category.name}</h4>
<span className="category-desc">{category.description}</span>
</div>
</div>
<div className="category-stats">
<div className="stat-item">
<span className="stat-label">전체</span>
<span className="stat-value">{stats.count}</span>
</div>
{hasRevisionMaterials && (
<div className="stat-item revision">
<span className="stat-label">리비전</span>
<span className="stat-value">{stats.processing_info.by_status.REVISION_MATERIAL}</span>
</div>
)}
{stats.processing_info?.by_status?.INVENTORY_MATERIAL > 0 && (
<div className="stat-item inventory">
<span className="stat-label">재고</span>
<span className="stat-value">{stats.processing_info.by_status.INVENTORY_MATERIAL}</span>
</div>
)}
</div>
{stats.count === 0 && (
<div className="empty-category">자료 없음</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
<div className="content-right">
{/* 비교 이력 */}
<div className="comparison-history">
<h3>📋 비교 이력</h3>
{comparisonHistory.length > 0 ? (
<div className="history-list">
{comparisonHistory.map(comp => (
<div key={comp.id} className={`history-item ${comp.is_applied ? 'applied' : 'pending'}`}>
<div className="history-header">
<span className="comparison-date">
{new Date(comp.comparison_date).toLocaleString()}
</span>
<span className={`status ${comp.is_applied ? 'applied' : 'pending'}`}>
{comp.is_applied ? '적용완료' : '대기중'}
</span>
</div>
<div className="history-summary">
{comp.summary_stats && (
<>
<span>구매완료 변경: {comp.summary_stats.purchased_increased + comp.summary_stats.purchased_decreased}</span>
<span>구매미완료 변경: {comp.summary_stats.unpurchased_increased + comp.summary_stats.unpurchased_decreased}</span>
<span>신규/삭제: {comp.summary_stats.new_materials + comp.summary_stats.deleted_materials}</span>
</>
)}
</div>
{!comp.is_applied && (
<button
className="btn-apply-small"
onClick={() => {
setSelectedComparison(comp.id);
setShowApplyDialog(true);
}}
disabled={loading}
>
적용
</button>
)}
</div>
))}
</div>
) : (
<p className="no-history">비교 이력이 없습니다.</p>
)}
</div>
</div>
</div>
{/* 적용 확인 다이얼로그 */}
<ConfirmDialog
isOpen={showApplyDialog}
title="변경사항 적용 확인"
message="리비전 변경사항을 실제 데이터베이스에 적용하시겠습니까? 이 작업은 되돌릴 수 없습니다."
onConfirm={() => handleApplyChanges(selectedComparison)}
onCancel={() => {
setShowApplyDialog(false);
setSelectedComparison(null);
}}
confirmText="적용"
cancelText="취소"
/>
</div>
);
};
export default EnhancedRevisionPage;

View File

@@ -1,463 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useRevisionLogic } from '../../hooks/useRevisionLogic';
import { useRevisionComparison } from '../../hooks/useRevisionComparison';
import { useRevisionStatus } from '../../hooks/useRevisionStatus';
import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
import './CategoryRevisionPage.css';
const BoltRevisionPage = ({
jobNo,
fileId,
previousFileId,
onNavigate,
user
}) => {
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [actionType, setActionType] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [sortBy, setSortBy] = useState('description');
const [sortOrder, setSortOrder] = useState('asc');
const [boltTypeFilter, setBoltTypeFilter] = useState('all');
const [threadTypeFilter, setThreadTypeFilter] = useState('all');
const [lengthFilter, setLengthFilter] = useState('all');
// 리비전 로직 훅
const {
materials,
loading: materialsLoading,
error: materialsError,
processingInfo,
handleMaterialSelection,
handleBulkAction,
refreshMaterials
} = useRevisionLogic(fileId, 'BOLT');
// 리비전 비교 훅
const {
comparisonResult,
loading: comparisonLoading,
error: comparisonError,
performComparison,
getFilteredComparison
} = useRevisionComparison(fileId, previousFileId);
// 리비전 상태 훅
const {
revisionStatus,
loading: statusLoading,
error: statusError,
uploadNewRevision,
navigateToRevision
} = useRevisionStatus(jobNo, fileId);
// 필터링 및 정렬된 자재 목록
const filteredAndSortedMaterials = useMemo(() => {
if (!materials) return [];
let filtered = materials.filter(material => {
const matchesSearch = !searchTerm ||
material.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.bolt_type?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' ||
material.processing_info?.display_status === statusFilter;
const matchesBoltType = boltTypeFilter === 'all' ||
material.bolt_type === boltTypeFilter;
const matchesThreadType = threadTypeFilter === 'all' ||
material.thread_type === threadTypeFilter;
const matchesLength = lengthFilter === 'all' ||
material.bolt_length === lengthFilter;
return matchesSearch && matchesStatus && matchesBoltType && matchesThreadType && matchesLength;
});
// 정렬
filtered.sort((a, b) => {
let aValue = a[sortBy] || '';
let bValue = b[sortBy] || '';
if (typeof aValue === 'string') {
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}
if (sortOrder === 'asc') {
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
} else {
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
}
});
return filtered;
}, [materials, searchTerm, statusFilter, boltTypeFilter, threadTypeFilter, lengthFilter, sortBy, sortOrder]);
// 고유 값들 추출 (필터 옵션용)
const uniqueValues = useMemo(() => {
if (!materials) return { boltTypes: [], threadTypes: [], lengths: [] };
const boltTypes = [...new Set(materials.map(m => m.bolt_type).filter(Boolean))];
const threadTypes = [...new Set(materials.map(m => m.thread_type).filter(Boolean))];
const lengths = [...new Set(materials.map(m => m.bolt_length).filter(Boolean))];
return { boltTypes, threadTypes, lengths };
}, [materials]);
// 초기 비교 수행
useEffect(() => {
if (fileId && previousFileId && !comparisonResult) {
performComparison('BOLT');
}
}, [fileId, previousFileId, comparisonResult, performComparison]);
// 자재 선택 처리
const handleMaterialSelect = (materialId, isSelected) => {
const newSelected = new Set(selectedMaterials);
if (isSelected) {
newSelected.add(materialId);
} else {
newSelected.delete(materialId);
}
setSelectedMaterials(newSelected);
handleMaterialSelection(materialId, isSelected);
};
// 전체 선택/해제
const handleSelectAll = (isSelected) => {
if (isSelected) {
const allIds = new Set(filteredAndSortedMaterials.map(m => m.id));
setSelectedMaterials(allIds);
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true));
} else {
setSelectedMaterials(new Set());
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false));
}
};
// 액션 실행
const executeAction = async (action) => {
setActionType(action);
setShowConfirmDialog(true);
};
const confirmAction = async () => {
try {
await handleBulkAction(actionType, Array.from(selectedMaterials));
setSelectedMaterials(new Set());
setShowConfirmDialog(false);
await refreshMaterials();
} catch (error) {
console.error('Action failed:', error);
}
};
// 상태별 색상 클래스
const getStatusClass = (status) => {
switch (status) {
case 'REVISION_MATERIAL': return 'status-revision';
case 'INVENTORY_MATERIAL': return 'status-inventory';
case 'DELETED_MATERIAL': return 'status-deleted';
case 'NEW_MATERIAL': return 'status-new';
default: return 'status-normal';
}
};
// 수량 표시 (정수로 변환)
const formatQuantity = (quantity) => {
if (quantity === null || quantity === undefined) return '-';
return Math.round(parseFloat(quantity) || 0).toString();
};
// BOLT 설명 생성 (볼트 타입과 규격 포함)
const generateBoltDescription = (material) => {
const parts = [];
if (material.bolt_type) parts.push(material.bolt_type);
if (material.thread_size) parts.push(material.thread_size);
if (material.bolt_length) parts.push(`L${material.bolt_length}mm`);
if (material.thread_type) parts.push(material.thread_type);
if (material.material_grade) parts.push(material.material_grade);
const baseDesc = material.description || material.item_name || 'BOLT';
return parts.length > 0 ? `${baseDesc} (${parts.join(', ')})` : baseDesc;
};
// 볼트 세트 정보 표시
const formatBoltSet = (material) => {
const parts = [];
if (material.bolt_count) parts.push(`볼트 ${material.bolt_count}`);
if (material.nut_count) parts.push(`너트 ${material.nut_count}`);
if (material.washer_count) parts.push(`와셔 ${material.washer_count}`);
return parts.length > 0 ? parts.join(' + ') : '-';
};
if (materialsLoading || comparisonLoading || statusLoading) {
return <LoadingSpinner message="BOLT 리비전 데이터 로딩 중..." />;
}
const error = materialsError || comparisonError || statusError;
if (error) {
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
}
return (
<div className="category-revision-page">
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate('enhanced-revision')}
>
리비전 관리로
</button>
<div className="header-center">
<h1>🔩 BOLT 리비전 관리</h1>
<span className="header-subtitle">
볼트 타입과 나사 규격을 고려한 BOLT 자재 리비전 처리
</span>
</div>
</div>
<div className="header-right">
<RevisionStatusIndicator
revisionStatus={revisionStatus}
onUploadRevision={uploadNewRevision}
onNavigateToRevision={navigateToRevision}
/>
</div>
</div>
{/* 컨트롤 섹션 */}
<div className="control-section">
<div className="section-header">
<h3>📊 자재 현황 필터</h3>
{processingInfo && (
<div className="processing-summary">
전체: {processingInfo.total_materials} |
리비전: {processingInfo.by_status.REVISION_MATERIAL || 0} |
재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0} |
삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}
</div>
)}
</div>
<div className="control-grid">
<div className="control-group">
<label>검색:</label>
<input
type="text"
placeholder="자재명, 볼트타입, 도면명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="control-group">
<label>상태 필터:</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">전체</option>
<option value="REVISION_MATERIAL">리비전 자재</option>
<option value="INVENTORY_MATERIAL">재고 자재</option>
<option value="DELETED_MATERIAL">삭제 자재</option>
<option value="NEW_MATERIAL">신규 자재</option>
</select>
</div>
<div className="control-group">
<label>볼트 타입:</label>
<select value={boltTypeFilter} onChange={(e) => setBoltTypeFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.boltTypes.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div className="control-group">
<label>나사 타입:</label>
<select value={threadTypeFilter} onChange={(e) => setThreadTypeFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.threadTypes.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div className="control-group">
<label>길이:</label>
<select value={lengthFilter} onChange={(e) => setLengthFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.lengths.map(length => (
<option key={length} value={length}>{length}mm</option>
))}
</select>
</div>
<div className="control-group">
<label>정렬:</label>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="description">자재명</option>
<option value="bolt_type">볼트 타입</option>
<option value="thread_size">나사 크기</option>
<option value="bolt_length">길이</option>
<option value="quantity">수량</option>
</select>
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
<option value="asc">오름차순</option>
<option value="desc">내림차순</option>
</select>
</div>
</div>
{/* 선택된 자재 액션 */}
{selectedMaterials.size > 0 && (
<div className="selected-actions">
<span className="selected-count">
{selectedMaterials.size} 선택됨
</span>
<div className="action-buttons">
<button
className="btn-action btn-purchase"
onClick={() => executeAction('request_purchase')}
>
구매 신청
</button>
<button
className="btn-action btn-inventory"
onClick={() => executeAction('mark_inventory')}
>
재고 처리
</button>
<button
className="btn-action btn-delete"
onClick={() => executeAction('mark_deleted')}
>
삭제 처리
</button>
</div>
</div>
)}
</div>
{/* 자재 테이블 */}
<div className="materials-table-container">
<div className="table-header">
<div className="header-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.size === filteredAndSortedMaterials.length && filteredAndSortedMaterials.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</div>
<div className="header-cell">상태</div>
<div className="header-cell">자재명</div>
<div className="header-cell">볼트 타입</div>
<div className="header-cell">나사 크기</div>
<div className="header-cell">길이</div>
<div className="header-cell">세트 구성</div>
<div className="header-cell">수량</div>
<div className="header-cell">단위</div>
<div className="header-cell">액션</div>
</div>
<div className="table-body">
{filteredAndSortedMaterials.map((material) => (
<div
key={material.id}
className={`table-row ${getStatusClass(material.processing_info?.display_status)}`}
>
<div className="table-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.has(material.id)}
onChange={(e) => handleMaterialSelect(material.id, e.target.checked)}
/>
</div>
<div className="table-cell">
<span className={`status-badge ${getStatusClass(material.processing_info?.display_status)}`}>
{material.processing_info?.display_status || 'NORMAL'}
</span>
</div>
<div className="table-cell">
<div className="material-info">
<div className="material-name">{generateBoltDescription(material)}</div>
{material.processing_info?.notes && (
<div className="material-notes">{material.processing_info.notes}</div>
)}
</div>
</div>
<div className="table-cell">{material.bolt_type || '-'}</div>
<div className="table-cell">{material.thread_size || '-'}</div>
<div className="table-cell">{material.bolt_length ? `${material.bolt_length}mm` : '-'}</div>
<div className="table-cell">{formatBoltSet(material)}</div>
<div className="table-cell quantity-cell">
<span className="quantity-value">
{formatQuantity(material.quantity)}
</span>
{material.processing_info?.quantity_change && (
<span className="quantity-change">
({material.processing_info.quantity_change > 0 ? '+' : ''}
{formatQuantity(material.processing_info.quantity_change)})
</span>
)}
</div>
<div className="table-cell">{material.unit || 'SET'}</div>
<div className="table-cell">
<div className="action-buttons-small">
<button
className="btn-small btn-view"
onClick={() => {/* 상세 보기 로직 */}}
title="상세 정보"
>
👁
</button>
<button
className="btn-small btn-edit"
onClick={() => {/* 편집 로직 */}}
title="편집"
>
</button>
<button
className="btn-small btn-torque"
onClick={() => {/* 토크 계산 로직 */}}
title="토크 계산"
>
🔧
</button>
</div>
</div>
</div>
))}
</div>
{filteredAndSortedMaterials.length === 0 && (
<div className="empty-state">
<p>조건에 맞는 BOLT 자재가 없습니다.</p>
</div>
)}
</div>
{/* 확인 다이얼로그 */}
{showConfirmDialog && (
<ConfirmDialog
title="작업 확인"
message={`선택된 ${selectedMaterials.size}개 자재에 대해 "${actionType}" 작업을 수행하시겠습니까?`}
onConfirm={confirmAction}
onCancel={() => setShowConfirmDialog(false)}
/>
)}
</div>
);
};
export default BoltRevisionPage;

View File

@@ -1,537 +0,0 @@
/* 카테고리별 리비전 페이지 공통 스타일 */
.category-revision-page {
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
min-height: 100vh;
padding: 20px;
}
/* 헤더 스타일 - 기존 materials-page와 통일 */
.materials-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 24px;
border: 1px solid #e2e8f0;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.back-button {
padding: 8px 16px;
background: #f1f5f9;
border: 1px solid #cbd5e1;
border-radius: 8px;
color: #475569;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.back-button:hover {
background: #e2e8f0;
border-color: #94a3b8;
color: #334155;
}
.header-center h1 {
margin: 0 0 4px 0;
font-size: 24px;
font-weight: 700;
color: #1e293b;
}
.header-subtitle {
color: #64748b;
font-size: 14px;
font-weight: 400;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
/* 컨트롤 섹션 */
.control-section {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 24px;
border: 1px solid #e2e8f0;
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.section-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.processing-summary {
font-size: 14px;
color: #64748b;
font-weight: 500;
}
.control-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
padding: 20px 24px;
}
.control-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.control-group label {
font-size: 14px;
font-weight: 600;
color: #374151;
}
.control-group input,
.control-group select {
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
background: white;
transition: all 0.2s ease;
}
.control-group input:focus,
.control-group select:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
/* 선택된 자재 액션 */
.selected-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: #f0f9ff;
border-top: 1px solid #e2e8f0;
}
.selected-count {
font-size: 14px;
font-weight: 600;
color: #0369a1;
}
.action-buttons {
display: flex;
gap: 8px;
}
.btn-action {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-purchase {
background: #10b981;
color: white;
}
.btn-purchase:hover {
background: #059669;
}
.btn-inventory {
background: #f59e0b;
color: white;
}
.btn-inventory:hover {
background: #d97706;
}
.btn-delete {
background: #ef4444;
color: white;
}
.btn-delete:hover {
background: #dc2626;
}
/* 자재 테이블 */
.materials-table-container {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e2e8f0;
overflow: hidden;
}
.table-header {
display: grid;
grid-template-columns: 50px 100px 2fr 1fr 80px 80px 1fr 1fr 100px;
gap: 12px;
padding: 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-weight: 600;
font-size: 14px;
color: #374151;
}
.header-cell {
display: flex;
align-items: center;
justify-content: flex-start;
}
.checkbox-cell {
justify-content: center;
}
.table-body {
max-height: 600px;
overflow-y: auto;
}
.table-row {
display: grid;
grid-template-columns: 50px 100px 2fr 1fr 80px 80px 1fr 1fr 100px;
gap: 12px;
padding: 16px;
border-bottom: 1px solid #f1f5f9;
font-size: 14px;
color: #374151;
transition: all 0.2s ease;
align-items: center;
}
.table-row:hover {
background: #f8fafc;
}
.table-row:last-child {
border-bottom: none;
}
.table-cell {
display: flex;
align-items: center;
justify-content: flex-start;
min-height: 40px;
}
.table-cell.checkbox-cell {
justify-content: center;
}
.table-cell.quantity-cell {
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
/* 상태별 스타일 */
.table-row.status-revision {
background: #fef3c7;
border-left: 4px solid #f59e0b;
}
.table-row.status-inventory {
background: #dbeafe;
border-left: 4px solid #3b82f6;
}
.table-row.status-deleted {
background: #fee2e2;
border-left: 4px solid #ef4444;
opacity: 0.7;
}
.table-row.status-new {
background: #dcfce7;
border-left: 4px solid #22c55e;
}
.table-row.status-normal {
background: white;
}
/* 상태 배지 */
.status-badge {
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
text-align: center;
min-width: 80px;
}
.status-badge.status-revision {
background: #fbbf24;
color: #92400e;
}
.status-badge.status-inventory {
background: #60a5fa;
color: #1e40af;
}
.status-badge.status-deleted {
background: #f87171;
color: #991b1b;
}
.status-badge.status-new {
background: #4ade80;
color: #166534;
}
.status-badge.status-normal {
background: #e5e7eb;
color: #374151;
}
/* 자재 정보 */
.material-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.material-name {
font-weight: 500;
color: #1f2937;
}
.material-notes {
font-size: 12px;
color: #6b7280;
font-style: italic;
}
/* 수량 표시 */
.quantity-value {
font-weight: 600;
color: #1f2937;
}
.quantity-change {
font-size: 12px;
color: #6b7280;
}
/* 액션 버튼 */
.action-buttons-small {
display: flex;
gap: 4px;
}
.btn-small {
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
background: white;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-small:hover {
background: #f9fafb;
border-color: #9ca3af;
}
.btn-view {
color: #3b82f6;
}
.btn-edit {
color: #f59e0b;
}
/* 빈 상태 */
.empty-state {
padding: 60px 20px;
text-align: center;
color: #6b7280;
}
.empty-state p {
margin: 0;
font-size: 16px;
}
/* 반응형 디자인 */
@media (max-width: 1400px) {
.table-header,
.table-row {
grid-template-columns: 50px 100px 2fr 1fr 80px 80px 1fr 100px;
}
.table-header .header-cell:nth-child(7),
.table-row .table-cell:nth-child(7) {
display: none;
}
}
@media (max-width: 1200px) {
.control-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.table-header,
.table-row {
grid-template-columns: 50px 100px 2fr 80px 80px 100px;
}
.table-header .header-cell:nth-child(4),
.table-row .table-cell:nth-child(4) {
display: none;
}
}
@media (max-width: 768px) {
.category-revision-page {
padding: 16px;
}
.materials-header {
flex-direction: column;
gap: 16px;
align-items: stretch;
}
.header-left {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.control-grid {
grid-template-columns: 1fr;
}
.selected-actions {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.action-buttons {
justify-content: stretch;
}
.btn-action {
flex: 1;
}
.table-header,
.table-row {
grid-template-columns: 1fr;
gap: 8px;
}
.table-header {
display: none;
}
.table-row {
flex-direction: column;
align-items: stretch;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 8px;
}
.table-cell {
justify-content: space-between;
padding: 4px 0;
border-bottom: 1px solid #f3f4f6;
}
.table-cell:last-child {
border-bottom: none;
}
.table-cell::before {
content: attr(data-label);
font-weight: 600;
color: #6b7280;
min-width: 80px;
}
.checkbox-cell::before {
content: "선택";
}
}
/* 애니메이션 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.table-row {
animation: fadeIn 0.3s ease-out;
}
.control-section,
.materials-table-container {
animation: fadeIn 0.4s ease-out;
}
/* 스크롤바 스타일 */
.table-body::-webkit-scrollbar {
width: 8px;
}
.table-body::-webkit-scrollbar-track {
background: #f1f5f9;
}
.table-body::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.table-body::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}

View File

@@ -1,141 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useRevisionLogic } from '../../hooks/useRevisionLogic';
import { useRevisionStatus } from '../../hooks/useRevisionStatus';
import { LoadingSpinner, ErrorMessage } from '../../components/common';
import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
import { FittingMaterialsView } from '../../components/bom';
import './CategoryRevisionPage.css';
const FittingRevisionPage = ({
jobNo,
fileId,
previousFileId,
onNavigate,
user
}) => {
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
const [userRequirements, setUserRequirements] = useState({});
const [purchasedMaterials, setPurchasedMaterials] = useState(new Set());
// 리비전 로직 훅
const {
materials,
loading: materialsLoading,
error: materialsError,
processingInfo,
handleMaterialSelection,
handleBulkAction,
refreshMaterials
} = useRevisionLogic(fileId, 'FITTING');
// 리비전 상태 훅
const {
revisionStatus,
loading: statusLoading,
error: statusError,
uploadNewRevision,
navigateToRevision
} = useRevisionStatus(jobNo, fileId);
// 자재 업데이트 함수
const updateMaterial = (materialId, updates) => {
// 리비전 페이지에서는 자재 업데이트 로직을 별도로 처리
console.log('Material update in revision page:', materialId, updates);
};
if (materialsLoading || statusLoading) {
return <LoadingSpinner message="FITTING 리비전 데이터 로딩 중..." />;
}
const error = materialsError || statusError;
if (error) {
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
}
// FITTING 카테고리만 필터링
const fittingMaterials = materials.filter(material =>
material.classified_category === 'FITTING' ||
material.category === 'FITTING'
);
// 기존 BOM 관리 페이지와 동일한 props 구성
const commonProps = {
materials: fittingMaterials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate: (materialIds) => {
setPurchasedMaterials(prev => {
const newSet = new Set(prev);
materialIds.forEach(id => newSet.add(id));
return newSet;
});
},
updateMaterial,
fileId,
jobNo,
user,
onNavigate
};
return (
<div className="category-revision-page">
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate('enhanced-revision')}
>
리비전 관리로
</button>
<div className="header-center">
<h1>🔧 FITTING 리비전 관리</h1>
<span className="header-subtitle">
구매 상태를 고려한 FITTING 자재 리비전 처리
</span>
</div>
</div>
<div className="header-right">
<RevisionStatusIndicator
revisionStatus={revisionStatus}
onUploadRevision={uploadNewRevision}
onNavigateToRevision={navigateToRevision}
/>
</div>
</div>
{/* 리비전 처리 상태 요약 */}
{processingInfo && (
<div className="revision-summary">
<h3>📊 리비전 처리 현황</h3>
<div className="summary-stats">
<div className="stat-item">
<span className="stat-label">전체 자재</span>
<span className="stat-value">{processingInfo.total_materials || 0}</span>
</div>
<div className="stat-item revision">
<span className="stat-label">리비전 자재</span>
<span className="stat-value">{processingInfo.by_status?.REVISION_MATERIAL || 0}</span>
</div>
<div className="stat-item inventory">
<span className="stat-label">재고 자재</span>
<span className="stat-value">{processingInfo.by_status?.INVENTORY_MATERIAL || 0}</span>
</div>
<div className="stat-item deleted">
<span className="stat-label">삭제 자재</span>
<span className="stat-value">{processingInfo.by_status?.DELETED_MATERIAL || 0}</span>
</div>
</div>
</div>
)}
{/* 기존 FITTING 자재 뷰 컴포넌트 사용 */}
<FittingMaterialsView {...commonProps} />
</div>
);
};
export default FittingRevisionPage;

View File

@@ -1,141 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useRevisionLogic } from '../../hooks/useRevisionLogic';
import { useRevisionStatus } from '../../hooks/useRevisionStatus';
import { LoadingSpinner, ErrorMessage } from '../../components/common';
import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
import { FlangeMaterialsView } from '../../components/bom';
import './CategoryRevisionPage.css';
const FlangeRevisionPage = ({
jobNo,
fileId,
previousFileId,
onNavigate,
user
}) => {
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
const [userRequirements, setUserRequirements] = useState({});
const [purchasedMaterials, setPurchasedMaterials] = useState(new Set());
// 리비전 로직 훅
const {
materials,
loading: materialsLoading,
error: materialsError,
processingInfo,
handleMaterialSelection,
handleBulkAction,
refreshMaterials
} = useRevisionLogic(fileId, 'FLANGE');
// 리비전 상태 훅
const {
revisionStatus,
loading: statusLoading,
error: statusError,
uploadNewRevision,
navigateToRevision
} = useRevisionStatus(jobNo, fileId);
// 자재 업데이트 함수
const updateMaterial = (materialId, updates) => {
// 리비전 페이지에서는 자재 업데이트 로직을 별도로 처리
console.log('Material update in revision page:', materialId, updates);
};
if (materialsLoading || statusLoading) {
return <LoadingSpinner message="FLANGE 리비전 데이터 로딩 중..." />;
}
const error = materialsError || statusError;
if (error) {
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
}
// FLANGE 카테고리만 필터링
const flangeMaterials = materials.filter(material =>
material.classified_category === 'FLANGE' ||
material.category === 'FLANGE'
);
// 기존 BOM 관리 페이지와 동일한 props 구성
const commonProps = {
materials: flangeMaterials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate: (materialIds) => {
setPurchasedMaterials(prev => {
const newSet = new Set(prev);
materialIds.forEach(id => newSet.add(id));
return newSet;
});
},
updateMaterial,
fileId,
jobNo,
user,
onNavigate
};
return (
<div className="category-revision-page">
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate('enhanced-revision')}
>
리비전 관리로
</button>
<div className="header-center">
<h1>🔩 FLANGE 리비전 관리</h1>
<span className="header-subtitle">
구매 상태를 고려한 FLANGE 자재 리비전 처리
</span>
</div>
</div>
<div className="header-right">
<RevisionStatusIndicator
revisionStatus={revisionStatus}
onUploadRevision={uploadNewRevision}
onNavigateToRevision={navigateToRevision}
/>
</div>
</div>
{/* 리비전 처리 상태 요약 */}
{processingInfo && (
<div className="revision-summary">
<h3>📊 리비전 처리 현황</h3>
<div className="summary-stats">
<div className="stat-item">
<span className="stat-label">전체 자재</span>
<span className="stat-value">{processingInfo.total_materials || 0}</span>
</div>
<div className="stat-item revision">
<span className="stat-label">리비전 자재</span>
<span className="stat-value">{processingInfo.by_status?.REVISION_MATERIAL || 0}</span>
</div>
<div className="stat-item inventory">
<span className="stat-label">재고 자재</span>
<span className="stat-value">{processingInfo.by_status?.INVENTORY_MATERIAL || 0}</span>
</div>
<div className="stat-item deleted">
<span className="stat-label">삭제 자재</span>
<span className="stat-value">{processingInfo.by_status?.DELETED_MATERIAL || 0}</span>
</div>
</div>
</div>
)}
{/* 기존 FLANGE 자재 뷰 컴포넌트 사용 */}
<FlangeMaterialsView {...commonProps} />
</div>
);
};
export default FlangeRevisionPage;

View File

@@ -1,459 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useRevisionLogic } from '../../hooks/useRevisionLogic';
import { useRevisionComparison } from '../../hooks/useRevisionComparison';
import { useRevisionStatus } from '../../hooks/useRevisionStatus';
import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
import './CategoryRevisionPage.css';
const GasketRevisionPage = ({
jobNo,
fileId,
previousFileId,
onNavigate,
user
}) => {
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [actionType, setActionType] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [sortBy, setSortBy] = useState('description');
const [sortOrder, setSortOrder] = useState('asc');
const [gasketTypeFilter, setGasketTypeFilter] = useState('all');
const [materialTypeFilter, setMaterialTypeFilter] = useState('all');
const [pressureRatingFilter, setPressureRatingFilter] = useState('all');
// 리비전 로직 훅
const {
materials,
loading: materialsLoading,
error: materialsError,
processingInfo,
handleMaterialSelection,
handleBulkAction,
refreshMaterials
} = useRevisionLogic(fileId, 'GASKET');
// 리비전 비교 훅
const {
comparisonResult,
loading: comparisonLoading,
error: comparisonError,
performComparison,
getFilteredComparison
} = useRevisionComparison(fileId, previousFileId);
// 리비전 상태 훅
const {
revisionStatus,
loading: statusLoading,
error: statusError,
uploadNewRevision,
navigateToRevision
} = useRevisionStatus(jobNo, fileId);
// 필터링 및 정렬된 자재 목록
const filteredAndSortedMaterials = useMemo(() => {
if (!materials) return [];
let filtered = materials.filter(material => {
const matchesSearch = !searchTerm ||
material.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.gasket_type?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' ||
material.processing_info?.display_status === statusFilter;
const matchesGasketType = gasketTypeFilter === 'all' ||
material.gasket_type === gasketTypeFilter;
const matchesMaterialType = materialTypeFilter === 'all' ||
material.material_type === materialTypeFilter;
const matchesPressureRating = pressureRatingFilter === 'all' ||
material.pressure_rating === pressureRatingFilter;
return matchesSearch && matchesStatus && matchesGasketType && matchesMaterialType && matchesPressureRating;
});
// 정렬
filtered.sort((a, b) => {
let aValue = a[sortBy] || '';
let bValue = b[sortBy] || '';
if (typeof aValue === 'string') {
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}
if (sortOrder === 'asc') {
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
} else {
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
}
});
return filtered;
}, [materials, searchTerm, statusFilter, gasketTypeFilter, materialTypeFilter, pressureRatingFilter, sortBy, sortOrder]);
// 고유 값들 추출 (필터 옵션용)
const uniqueValues = useMemo(() => {
if (!materials) return { gasketTypes: [], materialTypes: [], pressureRatings: [] };
const gasketTypes = [...new Set(materials.map(m => m.gasket_type).filter(Boolean))];
const materialTypes = [...new Set(materials.map(m => m.material_type).filter(Boolean))];
const pressureRatings = [...new Set(materials.map(m => m.pressure_rating).filter(Boolean))];
return { gasketTypes, materialTypes, pressureRatings };
}, [materials]);
// 초기 비교 수행
useEffect(() => {
if (fileId && previousFileId && !comparisonResult) {
performComparison('GASKET');
}
}, [fileId, previousFileId, comparisonResult, performComparison]);
// 자재 선택 처리
const handleMaterialSelect = (materialId, isSelected) => {
const newSelected = new Set(selectedMaterials);
if (isSelected) {
newSelected.add(materialId);
} else {
newSelected.delete(materialId);
}
setSelectedMaterials(newSelected);
handleMaterialSelection(materialId, isSelected);
};
// 전체 선택/해제
const handleSelectAll = (isSelected) => {
if (isSelected) {
const allIds = new Set(filteredAndSortedMaterials.map(m => m.id));
setSelectedMaterials(allIds);
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true));
} else {
setSelectedMaterials(new Set());
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false));
}
};
// 액션 실행
const executeAction = async (action) => {
setActionType(action);
setShowConfirmDialog(true);
};
const confirmAction = async () => {
try {
await handleBulkAction(actionType, Array.from(selectedMaterials));
setSelectedMaterials(new Set());
setShowConfirmDialog(false);
await refreshMaterials();
} catch (error) {
console.error('Action failed:', error);
}
};
// 상태별 색상 클래스
const getStatusClass = (status) => {
switch (status) {
case 'REVISION_MATERIAL': return 'status-revision';
case 'INVENTORY_MATERIAL': return 'status-inventory';
case 'DELETED_MATERIAL': return 'status-deleted';
case 'NEW_MATERIAL': return 'status-new';
default: return 'status-normal';
}
};
// 수량 표시 (정수로 변환)
const formatQuantity = (quantity) => {
if (quantity === null || quantity === undefined) return '-';
return Math.round(parseFloat(quantity) || 0).toString();
};
// GASKET 설명 생성 (가스켓 타입과 재질 포함)
const generateGasketDescription = (material) => {
const parts = [];
if (material.gasket_type) parts.push(material.gasket_type);
if (material.nominal_size) parts.push(material.nominal_size);
if (material.material_type) parts.push(material.material_type);
if (material.pressure_rating) parts.push(`${material.pressure_rating}#`);
const baseDesc = material.description || material.item_name || 'GASKET';
return parts.length > 0 ? `${baseDesc} (${parts.join(', ')})` : baseDesc;
};
// 가스켓 두께 표시
const formatThickness = (material) => {
if (material.thickness) return `${material.thickness}mm`;
if (material.gasket_thickness) return `${material.gasket_thickness}mm`;
return '-';
};
if (materialsLoading || comparisonLoading || statusLoading) {
return <LoadingSpinner message="GASKET 리비전 데이터 로딩 중..." />;
}
const error = materialsError || comparisonError || statusError;
if (error) {
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
}
return (
<div className="category-revision-page">
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate('enhanced-revision')}
>
리비전 관리로
</button>
<div className="header-center">
<h1> GASKET 리비전 관리</h1>
<span className="header-subtitle">
가스켓 타입과 재질을 고려한 GASKET 자재 리비전 처리
</span>
</div>
</div>
<div className="header-right">
<RevisionStatusIndicator
revisionStatus={revisionStatus}
onUploadRevision={uploadNewRevision}
onNavigateToRevision={navigateToRevision}
/>
</div>
</div>
{/* 컨트롤 섹션 */}
<div className="control-section">
<div className="section-header">
<h3>📊 자재 현황 필터</h3>
{processingInfo && (
<div className="processing-summary">
전체: {processingInfo.total_materials} |
리비전: {processingInfo.by_status.REVISION_MATERIAL || 0} |
재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0} |
삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}
</div>
)}
</div>
<div className="control-grid">
<div className="control-group">
<label>검색:</label>
<input
type="text"
placeholder="자재명, 가스켓타입, 도면명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="control-group">
<label>상태 필터:</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">전체</option>
<option value="REVISION_MATERIAL">리비전 자재</option>
<option value="INVENTORY_MATERIAL">재고 자재</option>
<option value="DELETED_MATERIAL">삭제 자재</option>
<option value="NEW_MATERIAL">신규 자재</option>
</select>
</div>
<div className="control-group">
<label>가스켓 타입:</label>
<select value={gasketTypeFilter} onChange={(e) => setGasketTypeFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.gasketTypes.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div className="control-group">
<label>재질:</label>
<select value={materialTypeFilter} onChange={(e) => setMaterialTypeFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.materialTypes.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div className="control-group">
<label>압력등급:</label>
<select value={pressureRatingFilter} onChange={(e) => setPressureRatingFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.pressureRatings.map(rating => (
<option key={rating} value={rating}>{rating}#</option>
))}
</select>
</div>
<div className="control-group">
<label>정렬:</label>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="description">자재명</option>
<option value="gasket_type">가스켓 타입</option>
<option value="nominal_size">크기</option>
<option value="material_type">재질</option>
<option value="quantity">수량</option>
</select>
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
<option value="asc">오름차순</option>
<option value="desc">내림차순</option>
</select>
</div>
</div>
{/* 선택된 자재 액션 */}
{selectedMaterials.size > 0 && (
<div className="selected-actions">
<span className="selected-count">
{selectedMaterials.size} 선택됨
</span>
<div className="action-buttons">
<button
className="btn-action btn-purchase"
onClick={() => executeAction('request_purchase')}
>
구매 신청
</button>
<button
className="btn-action btn-inventory"
onClick={() => executeAction('mark_inventory')}
>
재고 처리
</button>
<button
className="btn-action btn-delete"
onClick={() => executeAction('mark_deleted')}
>
삭제 처리
</button>
</div>
</div>
)}
</div>
{/* 자재 테이블 */}
<div className="materials-table-container">
<div className="table-header">
<div className="header-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.size === filteredAndSortedMaterials.length && filteredAndSortedMaterials.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</div>
<div className="header-cell">상태</div>
<div className="header-cell">자재명</div>
<div className="header-cell">가스켓 타입</div>
<div className="header-cell">크기</div>
<div className="header-cell">재질</div>
<div className="header-cell">두께</div>
<div className="header-cell">압력등급</div>
<div className="header-cell">수량</div>
<div className="header-cell">액션</div>
</div>
<div className="table-body">
{filteredAndSortedMaterials.map((material) => (
<div
key={material.id}
className={`table-row ${getStatusClass(material.processing_info?.display_status)}`}
>
<div className="table-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.has(material.id)}
onChange={(e) => handleMaterialSelect(material.id, e.target.checked)}
/>
</div>
<div className="table-cell">
<span className={`status-badge ${getStatusClass(material.processing_info?.display_status)}`}>
{material.processing_info?.display_status || 'NORMAL'}
</span>
</div>
<div className="table-cell">
<div className="material-info">
<div className="material-name">{generateGasketDescription(material)}</div>
{material.processing_info?.notes && (
<div className="material-notes">{material.processing_info.notes}</div>
)}
</div>
</div>
<div className="table-cell">{material.gasket_type || '-'}</div>
<div className="table-cell">{material.nominal_size || '-'}</div>
<div className="table-cell">{material.material_type || '-'}</div>
<div className="table-cell">{formatThickness(material)}</div>
<div className="table-cell">{material.pressure_rating ? `${material.pressure_rating}#` : '-'}</div>
<div className="table-cell quantity-cell">
<span className="quantity-value">
{formatQuantity(material.quantity)}
</span>
{material.processing_info?.quantity_change && (
<span className="quantity-change">
({material.processing_info.quantity_change > 0 ? '+' : ''}
{formatQuantity(material.processing_info.quantity_change)})
</span>
)}
</div>
<div className="table-cell">
<div className="action-buttons-small">
<button
className="btn-small btn-view"
onClick={() => {/* 상세 보기 로직 */}}
title="상세 정보"
>
👁
</button>
<button
className="btn-small btn-edit"
onClick={() => {/* 편집 로직 */}}
title="편집"
>
</button>
<button
className="btn-small btn-spec"
onClick={() => {/* 규격 확인 로직 */}}
title="규격 확인"
>
📏
</button>
</div>
</div>
</div>
))}
</div>
{filteredAndSortedMaterials.length === 0 && (
<div className="empty-state">
<p>조건에 맞는 GASKET 자재가 없습니다.</p>
</div>
)}
</div>
{/* 확인 다이얼로그 */}
{showConfirmDialog && (
<ConfirmDialog
title="작업 확인"
message={`선택된 ${selectedMaterials.size}개 자재에 대해 "${actionType}" 작업을 수행하시겠습니까?`}
onConfirm={confirmAction}
onCancel={() => setShowConfirmDialog(false)}
/>
)}
</div>
);
};
export default GasketRevisionPage;

View File

@@ -1,666 +0,0 @@
/* PIPE Cutting Plan 페이지 전용 스타일 */
/* PIPE 리비전 상태 표시 */
.revision-status-section {
margin: 20px 0;
padding: 0 20px;
}
.revision-alert {
display: flex;
align-items: flex-start;
gap: 15px;
padding: 20px;
border-radius: 12px;
border-left: 5px solid;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.revision-alert.pre-cutting {
border-left-color: #3b82f6;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
}
.revision-alert.post-cutting {
border-left-color: #f59e0b;
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
}
.alert-icon {
font-size: 24px;
margin-top: 2px;
}
.alert-content h4 {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
color: #1f2937;
}
.alert-content p {
margin: 0 0 12px 0;
color: #4b5563;
line-height: 1.5;
}
.revision-summary {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-top: 12px;
}
.revision-summary span {
display: inline-flex;
align-items: center;
padding: 4px 12px;
background: rgba(255, 255, 255, 0.8);
border-radius: 20px;
font-size: 13px;
font-weight: 500;
color: #374151;
border: 1px solid rgba(0, 0, 0, 0.1);
}
/* Cutting Plan 관리 섹션 */
.cutting-plan-management-section {
margin: 30px 0;
padding: 25px;
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
border-radius: 16px;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.cutting-plan-management-section .section-header h3 {
margin: 0 0 20px 0;
font-size: 20px;
font-weight: 600;
color: #1f2937;
display: flex;
align-items: center;
gap: 8px;
}
.cutting-plan-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.cutting-plan-actions button {
padding: 12px 20px;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 48px;
}
.btn-export-temp {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
}
.btn-export-temp:hover:not(:disabled) {
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.4);
}
.btn-finalize-cutting-plan {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
color: white;
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.3);
}
.btn-finalize-cutting-plan:hover:not(:disabled) {
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(220, 38, 38, 0.4);
}
.btn-export-finalized {
background: linear-gradient(135deg, #059669 0%, #047857 100%);
color: white;
box-shadow: 0 2px 4px rgba(5, 150, 105, 0.3);
}
.btn-export-finalized:hover:not(:disabled) {
background: linear-gradient(135deg, #047857 0%, #065f46 100%);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(5, 150, 105, 0.4);
}
.btn-issue-management {
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
color: white;
box-shadow: 0 2px 4px rgba(124, 58, 237, 0.3);
}
.btn-issue-management:hover:not(:disabled) {
background: linear-gradient(135deg, #6d28d9 0%, #5b21b6 100%);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(124, 58, 237, 0.4);
}
.cutting-plan-actions button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
.action-descriptions {
display: flex;
flex-direction: column;
gap: 8px;
padding: 15px;
background: rgba(249, 250, 251, 0.8);
border-radius: 8px;
border: 1px solid #e5e7eb;
}
.action-desc {
font-size: 13px;
color: #4b5563;
line-height: 1.4;
}
.action-desc strong {
color: #1f2937;
font-weight: 600;
}
/* 반응형 */
@media (max-width: 768px) {
.cutting-plan-actions {
grid-template-columns: 1fr;
}
.cutting-plan-actions button {
font-size: 13px;
padding: 10px 16px;
}
}
.pipe-cutting-plan-page {
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
min-height: 100vh;
padding: 20px;
}
/* 리비전 경고 섹션 */
.revision-warning {
background: linear-gradient(135deg, #fef3c7 0%, #fbbf24 100%);
border: 2px solid #f59e0b;
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.25);
}
.warning-content h3 {
margin: 0 0 12px 0;
color: #92400e;
font-size: 18px;
font-weight: 700;
}
.warning-content p {
margin: 0 0 16px 0;
color: #92400e;
font-size: 14px;
line-height: 1.5;
}
.highlight {
background: rgba(239, 68, 68, 0.2);
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
color: #dc2626;
}
.btn-force-upload {
background: #dc2626;
color: white;
border: none;
border-radius: 8px;
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-force-upload:hover {
background: #b91c1c;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.3);
}
/* 분류 섹션 */
.classification-section {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 24px;
border: 1px solid #e2e8f0;
overflow: hidden;
}
.classification-controls {
display: grid;
grid-template-columns: 200px 1fr 250px;
gap: 20px;
padding: 20px 24px;
align-items: end;
}
.control-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.control-group label {
font-size: 14px;
font-weight: 600;
color: #374151;
}
.control-group select,
.control-group input {
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
background: white;
transition: all 0.2s ease;
}
.control-group select:focus,
.control-group input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.control-group select:disabled,
.control-group input:disabled {
background: #f9fafb;
color: #9ca3af;
cursor: not-allowed;
}
.btn-start-cutting-plan {
padding: 12px 20px;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
height: fit-content;
}
.btn-start-cutting-plan:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.btn-start-cutting-plan:disabled {
background: #9ca3af;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* 자재 현황 요약 */
.materials-summary {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 20px 24px;
margin-bottom: 24px;
border: 1px solid #e2e8f0;
}
.summary-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 20px;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #f1f5f9;
}
.stat-label {
font-size: 12px;
font-weight: 500;
color: #64748b;
margin-bottom: 4px;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #1e293b;
}
/* Cutting Plan 콘텐츠 */
.cutting-plan-content {
display: flex;
flex-direction: column;
gap: 24px;
}
/* 구역 섹션 */
.area-section {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e2e8f0;
overflow: hidden;
}
.area-section.unassigned {
border-color: #fbbf24;
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
}
.area-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.area-section.unassigned .area-header {
background: #fbbf24;
color: white;
}
.area-header h4 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.area-section.unassigned .area-header h4 {
color: white;
}
.area-count {
font-size: 14px;
color: #64748b;
font-weight: 500;
}
.area-section.unassigned .area-count {
color: rgba(255, 255, 255, 0.9);
}
/* PIPE 테이블 */
.pipe-table {
width: 100%;
}
.pipe-table .table-header {
display: grid;
grid-template-columns: 100px 150px 120px 200px 100px 120px 100px;
gap: 12px;
padding: 16px 24px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-weight: 600;
font-size: 14px;
color: #374151;
}
.area-section.unassigned .pipe-table .table-header {
background: rgba(251, 191, 36, 0.1);
}
.pipe-table .table-body {
max-height: 400px;
overflow-y: auto;
}
.pipe-table .table-row {
display: grid;
grid-template-columns: 100px 150px 120px 200px 100px 120px 100px;
gap: 12px;
padding: 16px 24px;
border-bottom: 1px solid #f1f5f9;
font-size: 14px;
color: #374151;
transition: all 0.2s ease;
align-items: center;
}
.pipe-table .table-row:hover {
background: #f8fafc;
}
.pipe-table .table-row:last-child {
border-bottom: none;
}
.pipe-table .header-cell,
.pipe-table .table-cell {
display: flex;
align-items: center;
justify-content: flex-start;
min-height: 40px;
}
.pipe-table .table-cell select {
width: 100%;
padding: 6px 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 13px;
background: white;
}
.pipe-table .table-cell select:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
}
/* 액션 버튼 */
.action-buttons-small {
display: flex;
gap: 4px;
}
.btn-small {
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
background: white;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
min-width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-small:hover {
background: #f9fafb;
border-color: #9ca3af;
}
.btn-small.btn-edit {
color: #f59e0b;
}
.btn-small.btn-delete {
color: #ef4444;
}
.btn-small.btn-delete:hover {
background: #fef2f2;
border-color: #fca5a5;
}
/* 빈 상태 */
.empty-state {
padding: 60px 20px;
text-align: center;
color: #6b7280;
background: white;
border-radius: 12px;
border: 1px solid #e2e8f0;
}
.empty-state p {
margin: 0;
font-size: 16px;
}
/* 반응형 디자인 */
@media (max-width: 1400px) {
.pipe-table .table-header,
.pipe-table .table-row {
grid-template-columns: 80px 130px 100px 180px 80px 100px 80px;
}
}
@media (max-width: 1200px) {
.classification-controls {
grid-template-columns: 1fr;
gap: 16px;
}
.pipe-table .table-header,
.pipe-table .table-row {
grid-template-columns: 80px 120px 160px 80px 100px 80px;
}
.pipe-table .table-header .header-cell:nth-child(3),
.pipe-table .table-row .table-cell:nth-child(3) {
display: none;
}
}
@media (max-width: 768px) {
.pipe-cutting-plan-page {
padding: 16px;
}
.classification-controls {
padding: 16px;
}
.summary-stats {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
}
.pipe-table .table-header {
display: none;
}
.pipe-table .table-row {
grid-template-columns: 1fr;
gap: 8px;
flex-direction: column;
align-items: stretch;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 8px;
}
.pipe-table .table-cell {
justify-content: space-between;
padding: 4px 0;
border-bottom: 1px solid #f3f4f6;
}
.pipe-table .table-cell:last-child {
border-bottom: none;
justify-content: center;
}
.pipe-table .table-cell::before {
content: attr(data-label);
font-weight: 600;
color: #6b7280;
min-width: 80px;
}
.area-header {
flex-direction: column;
gap: 8px;
align-items: stretch;
text-align: center;
}
}
/* 애니메이션 */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.area-section {
animation: slideIn 0.3s ease-out;
}
.pipe-table .table-row {
animation: slideIn 0.2s ease-out;
}
/* 스크롤바 스타일 */
.pipe-table .table-body::-webkit-scrollbar {
width: 8px;
}
.pipe-table .table-body::-webkit-scrollbar-track {
background: #f1f5f9;
}
.pipe-table .table-body::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.pipe-table .table-body::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}

View File

@@ -1,681 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useRevisionLogic } from '../../hooks/useRevisionLogic';
import { useRevisionComparison } from '../../hooks/useRevisionComparison';
import { useRevisionStatus } from '../../hooks/useRevisionStatus';
import { usePipeRevision } from '../../hooks/usePipeRevision';
import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
import './CategoryRevisionPage.css';
import './PipeCuttingPlanPage.css';
const PipeCuttingPlanPage = ({
jobNo,
fileId,
previousFileId,
onNavigate,
user
}) => {
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [actionType, setActionType] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [selectedArea, setSelectedArea] = useState('');
const [searchDrawing, setSearchDrawing] = useState('');
const [cuttingPlanStarted, setCuttingPlanStarted] = useState(false);
const [areaAssignments, setAreaAssignments] = useState({});
const [endPreparations, setEndPreparations] = useState({});
// 리비전 로직 훅
const {
materials,
loading: materialsLoading,
error: materialsError,
processingInfo,
handleMaterialSelection,
handleBulkAction,
refreshMaterials
} = useRevisionLogic(fileId, 'PIPE');
// 리비전 비교 훅
const {
comparisonResult,
loading: comparisonLoading,
error: comparisonError,
performComparison,
getFilteredComparison
} = useRevisionComparison(fileId, previousFileId);
// PIPE 전용 리비전 훅
const {
revisionStatus: pipeRevisionStatus,
comparisonResult: pipeComparisonResult,
loading: pipeRevisionLoading,
error: pipeRevisionError,
checkRevisionStatus,
handlePreCuttingPlanRevision,
handlePostCuttingPlanRevision,
processRevisionAutomatically,
finalizeCuttingPlan,
getSnapshotStatus,
exportFinalizedExcel,
checkFinalizationStatus,
isPreCuttingPlan,
isPostCuttingPlan,
requiresAction
} = usePipeRevision(jobNo, fileId);
// 리비전 상태 훅
const {
revisionStatus,
loading: statusLoading,
error: statusError,
uploadNewRevision,
navigateToRevision
} = useRevisionStatus(jobNo, fileId);
// 구역 옵션
const areaOptions = ['#01', '#02', '#03', '#04', '#05', '#06', '#07', '#08', '#09', '#10'];
// 컴포넌트 마운트 시 데이터 로드 및 리비전 처리
useEffect(() => {
refreshMaterials();
// PIPE 리비전 자동 처리
if (jobNo && fileId && requiresAction) {
handlePipeRevisionAutomatically();
}
}, [refreshMaterials, jobNo, fileId, requiresAction]);
// PIPE 리비전 자동 처리 함수
const handlePipeRevisionAutomatically = async () => {
try {
const result = await processRevisionAutomatically();
if (result.success) {
if (result.type === 'pre_cutting_plan') {
// Cutting Plan 작성 전 리비전 - 기존 데이터 삭제됨
alert(`${result.message}\n새로운 Cutting Plan을 작성해주세요.`);
setCuttingPlanStarted(false);
setAreaAssignments({});
} else if (result.type === 'post_cutting_plan') {
// Cutting Plan 작성 후 리비전 - 비교 결과 표시
alert(`${result.message}\n변경사항을 검토해주세요.`);
setCuttingPlanStarted(true);
}
} else {
console.error('PIPE 리비전 자동 처리 실패:', result.message);
}
} catch (error) {
console.error('PIPE 리비전 자동 처리 중 오류:', error);
}
};
// 끝단 처리 옵션
const endPrepOptions = [
{ value: 'plain', label: '무개선' },
{ value: 'single_bevel', label: '한개선' },
{ value: 'double_bevel', label: '양개선' }
];
// 필터링된 자재 목록 (도면 검색 적용)
const filteredMaterials = useMemo(() => {
if (!materials) return [];
return materials.filter(material => {
const matchesDrawing = !searchDrawing ||
material.drawing_name?.toLowerCase().includes(searchDrawing.toLowerCase());
return matchesDrawing;
});
}, [materials, searchDrawing]);
// 구역별로 그룹화된 자재
const groupedMaterials = useMemo(() => {
const grouped = {
assigned: {},
unassigned: []
};
filteredMaterials.forEach(material => {
const assignedArea = areaAssignments[material.id];
if (assignedArea) {
if (!grouped.assigned[assignedArea]) {
grouped.assigned[assignedArea] = [];
}
grouped.assigned[assignedArea].push(material);
} else {
grouped.unassigned.push(material);
}
});
return grouped;
}, [filteredMaterials, areaAssignments]);
// 초기 비교 수행
useEffect(() => {
if (fileId && previousFileId && !comparisonResult) {
performComparison('PIPE');
}
}, [fileId, previousFileId, comparisonResult, performComparison]);
// Cutting Plan 시작
const handleStartCuttingPlan = () => {
if (!selectedArea) {
alert('구역을 선택해주세요.');
return;
}
// 선택된 구역과 검색된 도면에 맞는 자재들을 자동 할당
const newAssignments = { ...areaAssignments };
filteredMaterials.forEach(material => {
if (!newAssignments[material.id]) {
newAssignments[material.id] = selectedArea;
}
});
setAreaAssignments(newAssignments);
setCuttingPlanStarted(true);
};
// 구역 할당 변경
const handleAreaAssignment = (materialId, area) => {
setAreaAssignments(prev => ({
...prev,
[materialId]: area
}));
};
// 끝단 처리 변경
const handleEndPrepChange = (materialId, endPrep) => {
setEndPreparations(prev => ({
...prev,
[materialId]: endPrep
}));
};
// 자재 삭제
const handleRemoveMaterial = (materialId) => {
setSelectedMaterials(prev => {
const newSet = new Set(prev);
newSet.add(materialId);
return newSet;
});
setActionType('delete_pipe_segment');
setShowConfirmDialog(true);
};
// 액션 실행
const confirmAction = async () => {
try {
if (actionType === 'delete_pipe_segment') {
// PIPE 세그먼트 삭제 로직
console.log('Deleting pipe segments:', Array.from(selectedMaterials));
} else if (actionType === 'force_revision_upload') {
// 강제 리비전 업로드 로직
uploadNewRevision();
}
setSelectedMaterials(new Set());
setShowConfirmDialog(false);
await refreshMaterials();
} catch (error) {
console.error('Action failed:', error);
}
};
// 파이프 정보 포맷팅
const formatPipeInfo = (material) => {
const parts = [];
if (material.material_grade) parts.push(material.material_grade);
if (material.schedule) parts.push(material.schedule);
if (material.nominal_size) parts.push(material.nominal_size);
return parts.join(' ') || '-';
};
// 길이 포맷팅
const formatLength = (length) => {
if (!length) return '-';
return `${parseFloat(length).toFixed(1)}mm`;
};
// 임시 Excel 내보내기 (현재 작업 중인 데이터)
const handleExportTempExcel = async () => {
try {
alert('임시 Excel 내보내기 기능은 구현 예정입니다.\n현재 작업 중인 데이터를 기준으로 생성됩니다.');
} catch (error) {
console.error('임시 Excel 내보내기 실패:', error);
alert('Excel 내보내기에 실패했습니다.');
}
};
// Cutting Plan 확정
const handleFinalizeCuttingPlan = async () => {
try {
const confirmed = window.confirm(
'⚠️ Cutting Plan을 확정하시겠습니까?\n\n' +
'확정 후에는:\n' +
'• 데이터가 고정되어 리비전 영향을 받지 않습니다\n' +
'• 이슈 관리를 시작할 수 있습니다\n' +
'• Excel 내보내기가 고정된 데이터로 제공됩니다'
);
if (!confirmed) return;
const result = await finalizeCuttingPlan();
if (result && result.success) {
alert(`${result.message}\n\n스냅샷 ID: ${result.snapshot_id}\n총 단관: ${result.total_segments}`);
// 페이지 새로고침 또는 상태 업데이트
window.location.reload();
} else {
alert(`❌ Cutting Plan 확정 실패\n${result?.message || '알 수 없는 오류'}`);
}
} catch (error) {
console.error('Cutting Plan 확정 실패:', error);
alert('Cutting Plan 확정에 실패했습니다.');
}
};
// 확정된 Excel 내보내기 (고정된 데이터)
const handleExportFinalizedExcel = async () => {
try {
const result = await exportFinalizedExcel();
if (result && result.success) {
alert('✅ 확정된 Excel 파일이 다운로드되었습니다.\n이 파일은 리비전과 무관하게 고정된 데이터입니다.');
} else {
alert(`❌ Excel 내보내기 실패\n${result?.message || '알 수 없는 오류'}`);
}
} catch (error) {
console.error('확정된 Excel 내보내기 실패:', error);
alert('Excel 내보내기에 실패했습니다.');
}
};
// 이슈 관리 페이지로 이동
const handleGoToIssueManagement = async () => {
try {
const snapshotStatus = await getSnapshotStatus();
if (snapshotStatus && snapshotStatus.has_snapshot && snapshotStatus.is_locked) {
// 이슈 관리 페이지로 이동
onNavigate('pipe-issue-management');
} else {
alert('❌ 이슈 관리를 시작하려면 먼저 Cutting Plan을 확정해주세요.');
}
} catch (error) {
console.error('이슈 관리 페이지 이동 실패:', error);
alert('이슈 관리 페이지 접근에 실패했습니다.');
}
};
if (materialsLoading || comparisonLoading || statusLoading || pipeRevisionLoading) {
return <LoadingSpinner message="PIPE 데이터 로딩 중..." />;
}
const error = materialsError || comparisonError || statusError || pipeRevisionError;
if (error) {
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
}
return (
<div className="pipe-cutting-plan-page">
{/* PIPE 리비전 상태 표시 */}
{pipeRevisionStatus && requiresAction && (
<div className="revision-status-section">
<div className={`revision-alert ${isPreCuttingPlan ? 'pre-cutting' : 'post-cutting'}`}>
<div className="alert-icon">
{isPreCuttingPlan ? '🔄' : '⚠️'}
</div>
<div className="alert-content">
<h4>
{isPreCuttingPlan ? 'Cutting Plan 작성 전 리비전' : 'Cutting Plan 작성 후 리비전'}
</h4>
<p>{pipeRevisionStatus.message}</p>
{isPostCuttingPlan && pipeComparisonResult && (
<div className="revision-summary">
<span>변경된 도면: {pipeComparisonResult.summary?.changed_drawings_count || 0}</span>
<span>추가된 단관: {pipeComparisonResult.summary?.added_segments || 0}</span>
<span>삭제된 단관: {pipeComparisonResult.summary?.removed_segments || 0}</span>
<span>수정된 단관: {pipeComparisonResult.summary?.modified_segments || 0}</span>
</div>
)}
</div>
</div>
</div>
)}
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate('enhanced-revision')}
>
리비전 관리로
</button>
<div className="header-center">
<h1>🔧 PIPE Cutting Plan 관리</h1>
<span className="header-subtitle">
도면-라인번호-길이 기반 파이프 절단 계획 관리
</span>
</div>
</div>
<div className="header-right">
<RevisionStatusIndicator
revisionStatus={revisionStatus}
onUploadRevision={uploadNewRevision}
onNavigateToRevision={navigateToRevision}
/>
</div>
</div>
{/* 리비전 경고 (Cutting Plan 시작 전) */}
{!cuttingPlanStarted && (
<div className="revision-warning">
<div className="warning-content">
<h3> PIPE 리비전 처리 안내</h3>
<p>
<strong>Cutting Plan 작성 </strong> 리비전이 발생하면
<span className="highlight">기존 단관정보가 전부 삭제</span>되고
<span className="highlight"> BOM 파일 업로드</span> 필요합니다.
</p>
{revisionStatus?.has_revision && (
<button
className="btn-force-upload"
onClick={() => {
setActionType('force_revision_upload');
setShowConfirmDialog(true);
}}
>
🔄 BOM 파일 업로드
</button>
)}
</div>
</div>
)}
{/* 분류 섹션 */}
<div className="classification-section">
<div className="section-header">
<h3>📂 구역 도면 분류</h3>
</div>
<div className="classification-controls">
<div className="control-group">
<label>구역 선택:</label>
<select
value={selectedArea}
onChange={(e) => setSelectedArea(e.target.value)}
disabled={cuttingPlanStarted}
>
<option value="">구역을 선택하세요</option>
{areaOptions.map(area => (
<option key={area} value={area}>{area}</option>
))}
</select>
</div>
<div className="control-group">
<label>도면 검색:</label>
<input
type="text"
placeholder="도면명으로 검색..."
value={searchDrawing}
onChange={(e) => setSearchDrawing(e.target.value)}
/>
</div>
<div className="control-group">
<button
className="btn-start-cutting-plan"
onClick={handleStartCuttingPlan}
disabled={cuttingPlanStarted || !selectedArea}
>
{cuttingPlanStarted ? '✅ Cutting Plan 작성 중' : '📝 Cutting Plan 작성 시작'}
</button>
</div>
</div>
</div>
{/* 자재 현황 */}
<div className="materials-summary">
<div className="summary-stats">
<div className="stat-item">
<span className="stat-label">전체 단관</span>
<span className="stat-value">{filteredMaterials.length}</span>
</div>
<div className="stat-item">
<span className="stat-label">할당된 단관</span>
<span className="stat-value">{Object.keys(areaAssignments).length}</span>
</div>
<div className="stat-item">
<span className="stat-label">미할당 단관</span>
<span className="stat-value">{groupedMaterials.unassigned.length}</span>
</div>
</div>
</div>
{/* 구역별 자재 테이블 */}
<div className="cutting-plan-content">
{/* 할당된 구역들 */}
{Object.keys(groupedMaterials.assigned).sort().map(area => (
<div key={area} className="area-section">
<div className="area-header">
<h4>📍 구역 {area}</h4>
<span className="area-count">{groupedMaterials.assigned[area].length} 단관</span>
</div>
<div className="pipe-table">
<div className="table-header">
<div className="header-cell">구역</div>
<div className="header-cell">도면</div>
<div className="header-cell">라인번호</div>
<div className="header-cell">파이프정보(재질)</div>
<div className="header-cell">길이</div>
<div className="header-cell">끝단정보</div>
<div className="header-cell">액션</div>
</div>
<div className="table-body">
{groupedMaterials.assigned[area].map(material => (
<div key={material.id} className="table-row">
<div className="table-cell">
<select
value={areaAssignments[material.id] || ''}
onChange={(e) => handleAreaAssignment(material.id, e.target.value)}
>
<option value="">미할당</option>
{areaOptions.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
<div className="table-cell">{material.drawing_name || '-'}</div>
<div className="table-cell">{material.line_no || '-'}</div>
<div className="table-cell">{formatPipeInfo(material)}</div>
<div className="table-cell">{formatLength(material.length || material.total_length)}</div>
<div className="table-cell">
<select
value={endPreparations[material.id] || 'plain'}
onChange={(e) => handleEndPrepChange(material.id, e.target.value)}
>
{endPrepOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className="table-cell">
<div className="action-buttons-small">
<button
className="btn-small btn-edit"
onClick={() => {/* 편집 로직 */}}
title="편집"
>
</button>
<button
className="btn-small btn-delete"
onClick={() => handleRemoveMaterial(material.id)}
title="삭제"
>
</button>
</div>
</div>
</div>
))}
</div>
</div>
</div>
))}
{/* 미할당 단관들 */}
{groupedMaterials.unassigned.length > 0 && (
<div className="area-section unassigned">
<div className="area-header">
<h4> 미할당 단관</h4>
<span className="area-count">{groupedMaterials.unassigned.length} 단관</span>
</div>
<div className="pipe-table">
<div className="table-header">
<div className="header-cell">구역</div>
<div className="header-cell">도면</div>
<div className="header-cell">라인번호</div>
<div className="header-cell">파이프정보(재질)</div>
<div className="header-cell">길이</div>
<div className="header-cell">끝단정보</div>
<div className="header-cell">액션</div>
</div>
<div className="table-body">
{groupedMaterials.unassigned.map(material => (
<div key={material.id} className="table-row">
<div className="table-cell">
<select
value={areaAssignments[material.id] || ''}
onChange={(e) => handleAreaAssignment(material.id, e.target.value)}
>
<option value="">미할당</option>
{areaOptions.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
<div className="table-cell">{material.drawing_name || '-'}</div>
<div className="table-cell">{material.line_no || '-'}</div>
<div className="table-cell">{formatPipeInfo(material)}</div>
<div className="table-cell">{formatLength(material.length || material.total_length)}</div>
<div className="table-cell">
<select
value={endPreparations[material.id] || 'plain'}
onChange={(e) => handleEndPrepChange(material.id, e.target.value)}
>
{endPrepOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className="table-cell">
<div className="action-buttons-small">
<button
className="btn-small btn-edit"
onClick={() => {/* 편집 로직 */}}
title="편집"
>
</button>
<button
className="btn-small btn-delete"
onClick={() => handleRemoveMaterial(material.id)}
title="삭제"
>
</button>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* 빈 상태 */}
{filteredMaterials.length === 0 && (
<div className="empty-state">
<p>조건에 맞는 PIPE 자재가 없습니다.</p>
</div>
)}
</div>
{/* Cutting Plan 관리 액션 */}
<div className="cutting-plan-management-section">
<div className="section-header">
<h3>🔧 Cutting Plan 관리</h3>
</div>
<div className="cutting-plan-actions">
<button
className="btn-export-temp"
onClick={handleExportTempExcel}
disabled={pipeRevisionLoading}
>
📊 임시 Excel 내보내기
</button>
<button
className="btn-finalize-cutting-plan"
onClick={handleFinalizeCuttingPlan}
disabled={pipeRevisionLoading}
>
🔒 Cutting Plan 확정 (이슈 관리 시작)
</button>
<button
className="btn-export-finalized"
onClick={handleExportFinalizedExcel}
disabled={pipeRevisionLoading}
>
📋 확정된 Excel 내보내기 (고정)
</button>
<button
className="btn-issue-management"
onClick={handleGoToIssueManagement}
disabled={pipeRevisionLoading}
>
🛠 이슈 관리 페이지
</button>
</div>
<div className="action-descriptions">
<div className="action-desc">
<strong>📊 임시 Excel:</strong> 현재 작업 중인 데이터 (리비전 변경됨)
</div>
<div className="action-desc">
<strong>🔒 확정:</strong> 데이터 고정 이슈 관리 시작 (리비전 보호)
</div>
<div className="action-desc">
<strong>📋 확정된 Excel:</strong> 고정된 데이터 (리비전과 무관)
</div>
</div>
</div>
{/* 확인 다이얼로그 */}
{showConfirmDialog && (
<ConfirmDialog
title={actionType === 'force_revision_upload' ? '강제 리비전 업로드' : '작업 확인'}
message={
actionType === 'force_revision_upload'
? '기존 단관정보를 모두 삭제하고 새 BOM 파일을 업로드하시겠습니까?'
: `선택된 단관을 삭제하시겠습니까?`
}
onConfirm={confirmAction}
onCancel={() => setShowConfirmDialog(false)}
/>
)}
</div>
);
};
export default PipeCuttingPlanPage;

View File

@@ -1,460 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useRevisionLogic } from '../../hooks/useRevisionLogic';
import { useRevisionComparison } from '../../hooks/useRevisionComparison';
import { useRevisionStatus } from '../../hooks/useRevisionStatus';
import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
import './CategoryRevisionPage.css';
const SpecialRevisionPage = ({
jobNo,
fileId,
previousFileId,
onNavigate,
user
}) => {
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [actionType, setActionType] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [sortBy, setSortBy] = useState('description');
const [sortOrder, setSortOrder] = useState('asc');
const [subcategoryFilter, setSubcategoryFilter] = useState('all');
const [priorityFilter, setPriorityFilter] = useState('all');
// 리비전 로직 훅
const {
materials,
loading: materialsLoading,
error: materialsError,
processingInfo,
handleMaterialSelection,
handleBulkAction,
refreshMaterials
} = useRevisionLogic(fileId, 'SPECIAL');
// 리비전 비교 훅
const {
comparisonResult,
loading: comparisonLoading,
error: comparisonError,
performComparison,
getFilteredComparison
} = useRevisionComparison(fileId, previousFileId);
// 리비전 상태 훅
const {
revisionStatus,
loading: statusLoading,
error: statusError,
uploadNewRevision,
navigateToRevision
} = useRevisionStatus(jobNo, fileId);
// 필터링 및 정렬된 자재 목록
const filteredAndSortedMaterials = useMemo(() => {
if (!materials) return [];
let filtered = materials.filter(material => {
const matchesSearch = !searchTerm ||
material.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.brand?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' ||
material.processing_info?.display_status === statusFilter;
const matchesSubcategory = subcategoryFilter === 'all' ||
material.subcategory === subcategoryFilter;
const matchesPriority = priorityFilter === 'all' ||
material.processing_info?.priority === priorityFilter;
return matchesSearch && matchesStatus && matchesSubcategory && matchesPriority;
});
// 정렬
filtered.sort((a, b) => {
let aValue = a[sortBy] || '';
let bValue = b[sortBy] || '';
if (typeof aValue === 'string') {
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}
if (sortOrder === 'asc') {
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
} else {
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
}
});
return filtered;
}, [materials, searchTerm, statusFilter, subcategoryFilter, priorityFilter, sortBy, sortOrder]);
// 고유 값들 추출 (필터 옵션용)
const uniqueValues = useMemo(() => {
if (!materials) return { subcategories: [], priorities: [] };
const subcategories = [...new Set(materials.map(m => m.subcategory).filter(Boolean))];
const priorities = [...new Set(materials.map(m => m.processing_info?.priority).filter(Boolean))];
return { subcategories, priorities };
}, [materials]);
// 초기 비교 수행
useEffect(() => {
if (fileId && previousFileId && !comparisonResult) {
performComparison('SPECIAL');
}
}, [fileId, previousFileId, comparisonResult, performComparison]);
// 자재 선택 처리
const handleMaterialSelect = (materialId, isSelected) => {
const newSelected = new Set(selectedMaterials);
if (isSelected) {
newSelected.add(materialId);
} else {
newSelected.delete(materialId);
}
setSelectedMaterials(newSelected);
handleMaterialSelection(materialId, isSelected);
};
// 전체 선택/해제
const handleSelectAll = (isSelected) => {
if (isSelected) {
const allIds = new Set(filteredAndSortedMaterials.map(m => m.id));
setSelectedMaterials(allIds);
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true));
} else {
setSelectedMaterials(new Set());
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false));
}
};
// 액션 실행
const executeAction = async (action) => {
setActionType(action);
setShowConfirmDialog(true);
};
const confirmAction = async () => {
try {
await handleBulkAction(actionType, Array.from(selectedMaterials));
setSelectedMaterials(new Set());
setShowConfirmDialog(false);
await refreshMaterials();
} catch (error) {
console.error('Action failed:', error);
}
};
// 상태별 색상 클래스
const getStatusClass = (status) => {
switch (status) {
case 'REVISION_MATERIAL': return 'status-revision';
case 'INVENTORY_MATERIAL': return 'status-inventory';
case 'DELETED_MATERIAL': return 'status-deleted';
case 'NEW_MATERIAL': return 'status-new';
default: return 'status-normal';
}
};
// 우선순위별 색상 클래스
const getPriorityClass = (priority) => {
switch (priority) {
case 'high': return 'priority-high';
case 'medium': return 'priority-medium';
case 'low': return 'priority-low';
default: return 'priority-normal';
}
};
// 수량 표시 (정수로 변환)
const formatQuantity = (quantity) => {
if (quantity === null || quantity === undefined) return '-';
return Math.round(parseFloat(quantity) || 0).toString();
};
// SPECIAL 자재 설명 생성 (브랜드, 모델 포함)
const generateSpecialDescription = (material) => {
const parts = [];
if (material.brand) parts.push(`[${material.brand}]`);
if (material.description || material.item_name) {
parts.push(material.description || material.item_name);
}
if (material.model_number) parts.push(`(${material.model_number})`);
return parts.length > 0 ? parts.join(' ') : 'SPECIAL 자재';
};
if (materialsLoading || comparisonLoading || statusLoading) {
return <LoadingSpinner message="SPECIAL 리비전 데이터 로딩 중..." />;
}
const error = materialsError || comparisonError || statusError;
if (error) {
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
}
return (
<div className="category-revision-page">
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate('enhanced-revision')}
>
리비전 관리로
</button>
<div className="header-center">
<h1> SPECIAL 리비전 관리</h1>
<span className="header-subtitle">
특수 자재 브랜드별 SPECIAL 자재 리비전 처리
</span>
</div>
</div>
<div className="header-right">
<RevisionStatusIndicator
revisionStatus={revisionStatus}
onUploadRevision={uploadNewRevision}
onNavigateToRevision={navigateToRevision}
/>
</div>
</div>
{/* 컨트롤 섹션 */}
<div className="control-section">
<div className="section-header">
<h3>📊 자재 현황 필터</h3>
{processingInfo && (
<div className="processing-summary">
전체: {processingInfo.total_materials} |
리비전: {processingInfo.by_status.REVISION_MATERIAL || 0} |
재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0} |
삭제: {processingInfo.by_status.DELETED_MATERIAL || 0} |
높은 우선순위: {processingInfo.by_priority?.high || 0}
</div>
)}
</div>
<div className="control-grid">
<div className="control-group">
<label>검색:</label>
<input
type="text"
placeholder="자재명, 브랜드, 도면명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="control-group">
<label>상태 필터:</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">전체</option>
<option value="REVISION_MATERIAL">리비전 자재</option>
<option value="INVENTORY_MATERIAL">재고 자재</option>
<option value="DELETED_MATERIAL">삭제 자재</option>
<option value="NEW_MATERIAL">신규 자재</option>
</select>
</div>
<div className="control-group">
<label>서브카테고리:</label>
<select value={subcategoryFilter} onChange={(e) => setSubcategoryFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.subcategories.map(sub => (
<option key={sub} value={sub}>{sub}</option>
))}
</select>
</div>
<div className="control-group">
<label>우선순위:</label>
<select value={priorityFilter} onChange={(e) => setPriorityFilter(e.target.value)}>
<option value="all">전체</option>
<option value="high">높음</option>
<option value="medium">보통</option>
<option value="low">낮음</option>
</select>
</div>
<div className="control-group">
<label>정렬:</label>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="description">자재명</option>
<option value="brand">브랜드</option>
<option value="subcategory">서브카테고리</option>
<option value="quantity">수량</option>
</select>
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
<option value="asc">오름차순</option>
<option value="desc">내림차순</option>
</select>
</div>
</div>
{/* 선택된 자재 액션 */}
{selectedMaterials.size > 0 && (
<div className="selected-actions">
<span className="selected-count">
{selectedMaterials.size} 선택됨
</span>
<div className="action-buttons">
<button
className="btn-action btn-purchase"
onClick={() => executeAction('request_purchase')}
>
구매 신청
</button>
<button
className="btn-action btn-inventory"
onClick={() => executeAction('mark_inventory')}
>
재고 처리
</button>
<button
className="btn-action btn-delete"
onClick={() => executeAction('mark_deleted')}
>
삭제 처리
</button>
<button
className="btn-action btn-priority"
onClick={() => executeAction('set_high_priority')}
style={{ background: '#dc2626' }}
>
높은 우선순위
</button>
</div>
</div>
)}
</div>
{/* 자재 테이블 */}
<div className="materials-table-container">
<div className="table-header">
<div className="header-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.size === filteredAndSortedMaterials.length && filteredAndSortedMaterials.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</div>
<div className="header-cell">상태</div>
<div className="header-cell">우선순위</div>
<div className="header-cell">자재명</div>
<div className="header-cell">브랜드</div>
<div className="header-cell">도면명</div>
<div className="header-cell">수량</div>
<div className="header-cell">단위</div>
<div className="header-cell">액션</div>
</div>
<div className="table-body">
{filteredAndSortedMaterials.map((material) => (
<div
key={material.id}
className={`table-row ${getStatusClass(material.processing_info?.display_status)} ${getPriorityClass(material.processing_info?.priority)}`}
>
<div className="table-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.has(material.id)}
onChange={(e) => handleMaterialSelect(material.id, e.target.checked)}
/>
</div>
<div className="table-cell">
<span className={`status-badge ${getStatusClass(material.processing_info?.display_status)}`}>
{material.processing_info?.display_status || 'NORMAL'}
</span>
</div>
<div className="table-cell">
<span className={`priority-badge ${getPriorityClass(material.processing_info?.priority)}`}>
{material.processing_info?.priority === 'high' ? '🔴' :
material.processing_info?.priority === 'medium' ? '🟡' :
material.processing_info?.priority === 'low' ? '🟢' : '⚪'}
</span>
</div>
<div className="table-cell">
<div className="material-info">
<div className="material-name">{generateSpecialDescription(material)}</div>
{material.processing_info?.notes && (
<div className="material-notes">{material.processing_info.notes}</div>
)}
{material.subcategory && (
<div className="material-subcategory">📂 {material.subcategory}</div>
)}
</div>
</div>
<div className="table-cell">{material.brand || '-'}</div>
<div className="table-cell">{material.drawing_name || '-'}</div>
<div className="table-cell quantity-cell">
<span className="quantity-value">
{formatQuantity(material.quantity)}
</span>
{material.processing_info?.quantity_change && (
<span className="quantity-change">
({material.processing_info.quantity_change > 0 ? '+' : ''}
{formatQuantity(material.processing_info.quantity_change)})
</span>
)}
</div>
<div className="table-cell">{material.unit || 'EA'}</div>
<div className="table-cell">
<div className="action-buttons-small">
<button
className="btn-small btn-view"
onClick={() => {/* 상세 보기 로직 */}}
title="상세 정보"
>
👁
</button>
<button
className="btn-small btn-edit"
onClick={() => {/* 편집 로직 */}}
title="편집"
>
</button>
<button
className="btn-small btn-priority"
onClick={() => {/* 우선순위 변경 로직 */}}
title="우선순위 변경"
>
</button>
</div>
</div>
</div>
))}
</div>
{filteredAndSortedMaterials.length === 0 && (
<div className="empty-state">
<p>조건에 맞는 SPECIAL 자재가 없습니다.</p>
</div>
)}
</div>
{/* 확인 다이얼로그 */}
{showConfirmDialog && (
<ConfirmDialog
title="작업 확인"
message={`선택된 ${selectedMaterials.size}개 자재에 대해 "${actionType}" 작업을 수행하시겠습니까?`}
onConfirm={confirmAction}
onCancel={() => setShowConfirmDialog(false)}
/>
)}
</div>
);
};
export default SpecialRevisionPage;

View File

@@ -1,450 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useRevisionLogic } from '../../hooks/useRevisionLogic';
import { useRevisionComparison } from '../../hooks/useRevisionComparison';
import { useRevisionStatus } from '../../hooks/useRevisionStatus';
import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
import './CategoryRevisionPage.css';
const SupportRevisionPage = ({
jobNo,
fileId,
previousFileId,
onNavigate,
user
}) => {
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [actionType, setActionType] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [sortBy, setSortBy] = useState('description');
const [sortOrder, setSortOrder] = useState('asc');
const [supportTypeFilter, setSupportTypeFilter] = useState('all');
const [loadRatingFilter, setLoadRatingFilter] = useState('all');
// 리비전 로직 훅
const {
materials,
loading: materialsLoading,
error: materialsError,
processingInfo,
handleMaterialSelection,
handleBulkAction,
refreshMaterials
} = useRevisionLogic(fileId, 'SUPPORT');
// 리비전 비교 훅
const {
comparisonResult,
loading: comparisonLoading,
error: comparisonError,
performComparison,
getFilteredComparison
} = useRevisionComparison(fileId, previousFileId);
// 리비전 상태 훅
const {
revisionStatus,
loading: statusLoading,
error: statusError,
uploadNewRevision,
navigateToRevision
} = useRevisionStatus(jobNo, fileId);
// 필터링 및 정렬된 자재 목록
const filteredAndSortedMaterials = useMemo(() => {
if (!materials) return [];
let filtered = materials.filter(material => {
const matchesSearch = !searchTerm ||
material.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.support_type?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' ||
material.processing_info?.display_status === statusFilter;
const matchesSupportType = supportTypeFilter === 'all' ||
material.support_type === supportTypeFilter;
const matchesLoadRating = loadRatingFilter === 'all' ||
material.load_rating === loadRatingFilter;
return matchesSearch && matchesStatus && matchesSupportType && matchesLoadRating;
});
// 정렬
filtered.sort((a, b) => {
let aValue = a[sortBy] || '';
let bValue = b[sortBy] || '';
if (typeof aValue === 'string') {
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}
if (sortOrder === 'asc') {
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
} else {
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
}
});
return filtered;
}, [materials, searchTerm, statusFilter, supportTypeFilter, loadRatingFilter, sortBy, sortOrder]);
// 고유 값들 추출 (필터 옵션용)
const uniqueValues = useMemo(() => {
if (!materials) return { supportTypes: [], loadRatings: [] };
const supportTypes = [...new Set(materials.map(m => m.support_type).filter(Boolean))];
const loadRatings = [...new Set(materials.map(m => m.load_rating).filter(Boolean))];
return { supportTypes, loadRatings };
}, [materials]);
// 초기 비교 수행
useEffect(() => {
if (fileId && previousFileId && !comparisonResult) {
performComparison('SUPPORT');
}
}, [fileId, previousFileId, comparisonResult, performComparison]);
// 자재 선택 처리
const handleMaterialSelect = (materialId, isSelected) => {
const newSelected = new Set(selectedMaterials);
if (isSelected) {
newSelected.add(materialId);
} else {
newSelected.delete(materialId);
}
setSelectedMaterials(newSelected);
handleMaterialSelection(materialId, isSelected);
};
// 전체 선택/해제
const handleSelectAll = (isSelected) => {
if (isSelected) {
const allIds = new Set(filteredAndSortedMaterials.map(m => m.id));
setSelectedMaterials(allIds);
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true));
} else {
setSelectedMaterials(new Set());
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false));
}
};
// 액션 실행
const executeAction = async (action) => {
setActionType(action);
setShowConfirmDialog(true);
};
const confirmAction = async () => {
try {
await handleBulkAction(actionType, Array.from(selectedMaterials));
setSelectedMaterials(new Set());
setShowConfirmDialog(false);
await refreshMaterials();
} catch (error) {
console.error('Action failed:', error);
}
};
// 상태별 색상 클래스
const getStatusClass = (status) => {
switch (status) {
case 'REVISION_MATERIAL': return 'status-revision';
case 'INVENTORY_MATERIAL': return 'status-inventory';
case 'DELETED_MATERIAL': return 'status-deleted';
case 'NEW_MATERIAL': return 'status-new';
default: return 'status-normal';
}
};
// 수량 표시 (정수로 변환)
const formatQuantity = (quantity) => {
if (quantity === null || quantity === undefined) return '-';
return Math.round(parseFloat(quantity) || 0).toString();
};
// SUPPORT 자재 설명 생성 (지지대 타입과 하중 정보 포함)
const generateSupportDescription = (material) => {
const parts = [];
if (material.support_type) parts.push(material.support_type);
if (material.pipe_size) parts.push(`${material.pipe_size}"`);
if (material.load_rating) parts.push(`${material.load_rating} 등급`);
if (material.material_grade) parts.push(material.material_grade);
const baseDesc = material.description || material.item_name || 'SUPPORT';
return parts.length > 0 ? `${baseDesc} (${parts.join(', ')})` : baseDesc;
};
// 치수 정보 표시
const formatDimensions = (material) => {
const dims = [];
if (material.length_mm) dims.push(`L${material.length_mm}`);
if (material.width_mm) dims.push(`W${material.width_mm}`);
if (material.height_mm) dims.push(`H${material.height_mm}`);
return dims.length > 0 ? dims.join('×') : '-';
};
if (materialsLoading || comparisonLoading || statusLoading) {
return <LoadingSpinner message="SUPPORT 리비전 데이터 로딩 중..." />;
}
const error = materialsError || comparisonError || statusError;
if (error) {
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
}
return (
<div className="category-revision-page">
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate('enhanced-revision')}
>
리비전 관리로
</button>
<div className="header-center">
<h1>🏗 SUPPORT 리비전 관리</h1>
<span className="header-subtitle">
지지대 타입과 하중등급을 고려한 SUPPORT 자재 리비전 처리
</span>
</div>
</div>
<div className="header-right">
<RevisionStatusIndicator
revisionStatus={revisionStatus}
onUploadRevision={uploadNewRevision}
onNavigateToRevision={navigateToRevision}
/>
</div>
</div>
{/* 컨트롤 섹션 */}
<div className="control-section">
<div className="section-header">
<h3>📊 자재 현황 필터</h3>
{processingInfo && (
<div className="processing-summary">
전체: {processingInfo.total_materials} |
리비전: {processingInfo.by_status.REVISION_MATERIAL || 0} |
재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0} |
삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}
</div>
)}
</div>
<div className="control-grid">
<div className="control-group">
<label>검색:</label>
<input
type="text"
placeholder="자재명, 지지대 타입, 도면명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="control-group">
<label>상태 필터:</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">전체</option>
<option value="REVISION_MATERIAL">리비전 자재</option>
<option value="INVENTORY_MATERIAL">재고 자재</option>
<option value="DELETED_MATERIAL">삭제 자재</option>
<option value="NEW_MATERIAL">신규 자재</option>
</select>
</div>
<div className="control-group">
<label>지지대 타입:</label>
<select value={supportTypeFilter} onChange={(e) => setSupportTypeFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.supportTypes.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div className="control-group">
<label>하중등급:</label>
<select value={loadRatingFilter} onChange={(e) => setLoadRatingFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.loadRatings.map(rating => (
<option key={rating} value={rating}>{rating}</option>
))}
</select>
</div>
<div className="control-group">
<label>정렬:</label>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="description">자재명</option>
<option value="support_type">지지대 타입</option>
<option value="load_rating">하중등급</option>
<option value="pipe_size">파이프 크기</option>
<option value="quantity">수량</option>
</select>
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
<option value="asc">오름차순</option>
<option value="desc">내림차순</option>
</select>
</div>
</div>
{/* 선택된 자재 액션 */}
{selectedMaterials.size > 0 && (
<div className="selected-actions">
<span className="selected-count">
{selectedMaterials.size} 선택됨
</span>
<div className="action-buttons">
<button
className="btn-action btn-purchase"
onClick={() => executeAction('request_purchase')}
>
구매 신청
</button>
<button
className="btn-action btn-inventory"
onClick={() => executeAction('mark_inventory')}
>
재고 처리
</button>
<button
className="btn-action btn-delete"
onClick={() => executeAction('mark_deleted')}
>
삭제 처리
</button>
</div>
</div>
)}
</div>
{/* 자재 테이블 */}
<div className="materials-table-container">
<div className="table-header">
<div className="header-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.size === filteredAndSortedMaterials.length && filteredAndSortedMaterials.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</div>
<div className="header-cell">상태</div>
<div className="header-cell">자재명</div>
<div className="header-cell">지지대 타입</div>
<div className="header-cell">파이프 크기</div>
<div className="header-cell">하중등급</div>
<div className="header-cell">치수</div>
<div className="header-cell">수량</div>
<div className="header-cell">단위</div>
<div className="header-cell">액션</div>
</div>
<div className="table-body">
{filteredAndSortedMaterials.map((material) => (
<div
key={material.id}
className={`table-row ${getStatusClass(material.processing_info?.display_status)}`}
>
<div className="table-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.has(material.id)}
onChange={(e) => handleMaterialSelect(material.id, e.target.checked)}
/>
</div>
<div className="table-cell">
<span className={`status-badge ${getStatusClass(material.processing_info?.display_status)}`}>
{material.processing_info?.display_status || 'NORMAL'}
</span>
</div>
<div className="table-cell">
<div className="material-info">
<div className="material-name">{generateSupportDescription(material)}</div>
{material.processing_info?.notes && (
<div className="material-notes">{material.processing_info.notes}</div>
)}
{material.load_capacity && (
<div className="material-capacity">💪 하중용량: {material.load_capacity}</div>
)}
</div>
</div>
<div className="table-cell">{material.support_type || '-'}</div>
<div className="table-cell">{material.pipe_size ? `${material.pipe_size}"` : '-'}</div>
<div className="table-cell">{material.load_rating || '-'}</div>
<div className="table-cell">{formatDimensions(material)}</div>
<div className="table-cell quantity-cell">
<span className="quantity-value">
{formatQuantity(material.quantity)}
</span>
{material.processing_info?.quantity_change && (
<span className="quantity-change">
({material.processing_info.quantity_change > 0 ? '+' : ''}
{formatQuantity(material.processing_info.quantity_change)})
</span>
)}
</div>
<div className="table-cell">{material.unit || 'EA'}</div>
<div className="table-cell">
<div className="action-buttons-small">
<button
className="btn-small btn-view"
onClick={() => {/* 상세 보기 로직 */}}
title="상세 정보"
>
👁
</button>
<button
className="btn-small btn-edit"
onClick={() => {/* 편집 로직 */}}
title="편집"
>
</button>
<button
className="btn-small btn-calc"
onClick={() => {/* 하중 계산 로직 */}}
title="하중 계산"
>
🧮
</button>
</div>
</div>
</div>
))}
</div>
{filteredAndSortedMaterials.length === 0 && (
<div className="empty-state">
<p>조건에 맞는 SUPPORT 자재가 없습니다.</p>
</div>
)}
</div>
{/* 확인 다이얼로그 */}
{showConfirmDialog && (
<ConfirmDialog
title="작업 확인"
message={`선택된 ${selectedMaterials.size}개 자재에 대해 "${actionType}" 작업을 수행하시겠습니까?`}
onConfirm={confirmAction}
onCancel={() => setShowConfirmDialog(false)}
/>
)}
</div>
);
};
export default SupportRevisionPage;

View File

@@ -1,483 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useRevisionLogic } from '../../hooks/useRevisionLogic';
import { useRevisionComparison } from '../../hooks/useRevisionComparison';
import { useRevisionStatus } from '../../hooks/useRevisionStatus';
import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
import './CategoryRevisionPage.css';
const UnclassifiedRevisionPage = ({
jobNo,
fileId,
previousFileId,
onNavigate,
user
}) => {
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [actionType, setActionType] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [sortBy, setSortBy] = useState('description');
const [sortOrder, setSortOrder] = useState('asc');
const [classificationFilter, setClassificationFilter] = useState('all');
const [showClassificationTools, setShowClassificationTools] = useState(false);
// 리비전 로직 훅
const {
materials,
loading: materialsLoading,
error: materialsError,
processingInfo,
handleMaterialSelection,
handleBulkAction,
refreshMaterials
} = useRevisionLogic(fileId, 'UNCLASSIFIED');
// 리비전 비교 훅
const {
comparisonResult,
loading: comparisonLoading,
error: comparisonError,
performComparison,
getFilteredComparison
} = useRevisionComparison(fileId, previousFileId);
// 리비전 상태 훅
const {
revisionStatus,
loading: statusLoading,
error: statusError,
uploadNewRevision,
navigateToRevision
} = useRevisionStatus(jobNo, fileId);
// 필터링 및 정렬된 자재 목록
const filteredAndSortedMaterials = useMemo(() => {
if (!materials) return [];
let filtered = materials.filter(material => {
const matchesSearch = !searchTerm ||
material.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' ||
material.processing_info?.display_status === statusFilter;
const matchesClassification = classificationFilter === 'all' ||
(classificationFilter === 'needs_classification' && material.classification_confidence < 0.5) ||
(classificationFilter === 'low_confidence' && material.classification_confidence >= 0.5 && material.classification_confidence < 0.8) ||
(classificationFilter === 'high_confidence' && material.classification_confidence >= 0.8);
return matchesSearch && matchesStatus && matchesClassification;
});
// 정렬
filtered.sort((a, b) => {
let aValue = a[sortBy] || '';
let bValue = b[sortBy] || '';
if (typeof aValue === 'string') {
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}
if (sortOrder === 'asc') {
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
} else {
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
}
});
return filtered;
}, [materials, searchTerm, statusFilter, classificationFilter, sortBy, sortOrder]);
// 분류 신뢰도별 통계
const classificationStats = useMemo(() => {
if (!materials) return { needsClassification: 0, lowConfidence: 0, highConfidence: 0 };
return materials.reduce((stats, material) => {
const confidence = material.classification_confidence || 0;
if (confidence < 0.5) stats.needsClassification++;
else if (confidence < 0.8) stats.lowConfidence++;
else stats.highConfidence++;
return stats;
}, { needsClassification: 0, lowConfidence: 0, highConfidence: 0 });
}, [materials]);
// 초기 비교 수행
useEffect(() => {
if (fileId && previousFileId && !comparisonResult) {
performComparison('UNCLASSIFIED');
}
}, [fileId, previousFileId, comparisonResult, performComparison]);
// 자재 선택 처리
const handleMaterialSelect = (materialId, isSelected) => {
const newSelected = new Set(selectedMaterials);
if (isSelected) {
newSelected.add(materialId);
} else {
newSelected.delete(materialId);
}
setSelectedMaterials(newSelected);
handleMaterialSelection(materialId, isSelected);
};
// 전체 선택/해제
const handleSelectAll = (isSelected) => {
if (isSelected) {
const allIds = new Set(filteredAndSortedMaterials.map(m => m.id));
setSelectedMaterials(allIds);
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true));
} else {
setSelectedMaterials(new Set());
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false));
}
};
// 액션 실행
const executeAction = async (action) => {
setActionType(action);
setShowConfirmDialog(true);
};
const confirmAction = async () => {
try {
await handleBulkAction(actionType, Array.from(selectedMaterials));
setSelectedMaterials(new Set());
setShowConfirmDialog(false);
await refreshMaterials();
} catch (error) {
console.error('Action failed:', error);
}
};
// 상태별 색상 클래스
const getStatusClass = (status) => {
switch (status) {
case 'REVISION_MATERIAL': return 'status-revision';
case 'INVENTORY_MATERIAL': return 'status-inventory';
case 'DELETED_MATERIAL': return 'status-deleted';
case 'NEW_MATERIAL': return 'status-new';
default: return 'status-normal';
}
};
// 분류 신뢰도별 색상 클래스
const getConfidenceClass = (confidence) => {
if (confidence < 0.5) return 'confidence-low';
if (confidence < 0.8) return 'confidence-medium';
return 'confidence-high';
};
// 수량 표시 (정수로 변환)
const formatQuantity = (quantity) => {
if (quantity === null || quantity === undefined) return '-';
return Math.round(parseFloat(quantity) || 0).toString();
};
// 분류 신뢰도 표시
const formatConfidence = (confidence) => {
if (confidence === null || confidence === undefined) return '0%';
return `${Math.round(confidence * 100)}%`;
};
// 분류 제안 카테고리 표시
const getSuggestedCategory = (material) => {
// 간단한 키워드 기반 분류 제안
const desc = (material.description || material.item_name || '').toLowerCase();
if (desc.includes('pipe') || desc.includes('파이프')) return 'PIPE';
if (desc.includes('flange') || desc.includes('플랜지')) return 'FLANGE';
if (desc.includes('fitting') || desc.includes('피팅')) return 'FITTING';
if (desc.includes('support') || desc.includes('지지대')) return 'SUPPORT';
if (desc.includes('valve') || desc.includes('밸브')) return 'SPECIAL';
return '수동 분류 필요';
};
if (materialsLoading || comparisonLoading || statusLoading) {
return <LoadingSpinner message="UNCLASSIFIED 리비전 데이터 로딩 중..." />;
}
const error = materialsError || comparisonError || statusError;
if (error) {
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
}
return (
<div className="category-revision-page">
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate('enhanced-revision')}
>
리비전 관리로
</button>
<div className="header-center">
<h1> UNCLASSIFIED 리비전 관리</h1>
<span className="header-subtitle">
미분류 자재의 리비전 처리 분류 작업
</span>
</div>
</div>
<div className="header-right">
<RevisionStatusIndicator
revisionStatus={revisionStatus}
onUploadRevision={uploadNewRevision}
onNavigateToRevision={navigateToRevision}
/>
</div>
</div>
{/* 분류 통계 카드 */}
<div className="classification-stats-card">
<div className="stats-header">
<h3>🔍 분류 현황</h3>
<button
className="btn-toggle-tools"
onClick={() => setShowClassificationTools(!showClassificationTools)}
>
{showClassificationTools ? '도구 숨기기' : '분류 도구 보기'}
</button>
</div>
<div className="stats-grid">
<div className="stat-item needs-classification">
<div className="stat-value">{classificationStats.needsClassification}</div>
<div className="stat-label">분류 필요</div>
</div>
<div className="stat-item low-confidence">
<div className="stat-value">{classificationStats.lowConfidence}</div>
<div className="stat-label">낮은 신뢰도</div>
</div>
<div className="stat-item high-confidence">
<div className="stat-value">{classificationStats.highConfidence}</div>
<div className="stat-label">높은 신뢰도</div>
</div>
</div>
</div>
{/* 컨트롤 섹션 */}
<div className="control-section">
<div className="section-header">
<h3>📊 자재 현황 필터</h3>
{processingInfo && (
<div className="processing-summary">
전체: {processingInfo.total_materials} |
리비전: {processingInfo.by_status.REVISION_MATERIAL || 0} |
재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0} |
삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}
</div>
)}
</div>
<div className="control-grid">
<div className="control-group">
<label>검색:</label>
<input
type="text"
placeholder="자재명, 도면명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="control-group">
<label>상태 필터:</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">전체</option>
<option value="REVISION_MATERIAL">리비전 자재</option>
<option value="INVENTORY_MATERIAL">재고 자재</option>
<option value="DELETED_MATERIAL">삭제 자재</option>
<option value="NEW_MATERIAL">신규 자재</option>
</select>
</div>
<div className="control-group">
<label>분류 상태:</label>
<select value={classificationFilter} onChange={(e) => setClassificationFilter(e.target.value)}>
<option value="all">전체</option>
<option value="needs_classification">분류 필요 (&lt;50%)</option>
<option value="low_confidence">낮은 신뢰도 (50-80%)</option>
<option value="high_confidence">높은 신뢰도 (&gt;80%)</option>
</select>
</div>
<div className="control-group">
<label>정렬:</label>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="description">자재명</option>
<option value="classification_confidence">분류 신뢰도</option>
<option value="quantity">수량</option>
<option value="drawing_name">도면명</option>
</select>
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
<option value="asc">오름차순</option>
<option value="desc">내림차순</option>
</select>
</div>
</div>
{/* 선택된 자재 액션 */}
{selectedMaterials.size > 0 && (
<div className="selected-actions">
<span className="selected-count">
{selectedMaterials.size} 선택됨
</span>
<div className="action-buttons">
<button
className="btn-action btn-purchase"
onClick={() => executeAction('request_purchase')}
>
구매 신청
</button>
<button
className="btn-action btn-inventory"
onClick={() => executeAction('mark_inventory')}
>
재고 처리
</button>
<button
className="btn-action btn-delete"
onClick={() => executeAction('mark_deleted')}
>
삭제 처리
</button>
<button
className="btn-action btn-classify"
onClick={() => executeAction('auto_classify')}
style={{ background: '#8b5cf6' }}
>
자동 분류
</button>
</div>
</div>
)}
</div>
{/* 자재 테이블 */}
<div className="materials-table-container">
<div className="table-header">
<div className="header-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.size === filteredAndSortedMaterials.length && filteredAndSortedMaterials.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</div>
<div className="header-cell">상태</div>
<div className="header-cell">자재명</div>
<div className="header-cell">분류 신뢰도</div>
<div className="header-cell">제안 카테고리</div>
<div className="header-cell">도면명</div>
<div className="header-cell">수량</div>
<div className="header-cell">단위</div>
<div className="header-cell">액션</div>
</div>
<div className="table-body">
{filteredAndSortedMaterials.map((material) => (
<div
key={material.id}
className={`table-row ${getStatusClass(material.processing_info?.display_status)} ${getConfidenceClass(material.classification_confidence)}`}
>
<div className="table-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.has(material.id)}
onChange={(e) => handleMaterialSelect(material.id, e.target.checked)}
/>
</div>
<div className="table-cell">
<span className={`status-badge ${getStatusClass(material.processing_info?.display_status)}`}>
{material.processing_info?.display_status || 'NORMAL'}
</span>
</div>
<div className="table-cell">
<div className="material-info">
<div className="material-name">{material.description || material.item_name || '자재명 없음'}</div>
{material.processing_info?.notes && (
<div className="material-notes">{material.processing_info.notes}</div>
)}
</div>
</div>
<div className="table-cell">
<span className={`confidence-badge ${getConfidenceClass(material.classification_confidence)}`}>
{formatConfidence(material.classification_confidence)}
</span>
</div>
<div className="table-cell">
<span className="suggested-category">
{getSuggestedCategory(material)}
</span>
</div>
<div className="table-cell">{material.drawing_name || '-'}</div>
<div className="table-cell quantity-cell">
<span className="quantity-value">
{formatQuantity(material.quantity)}
</span>
{material.processing_info?.quantity_change && (
<span className="quantity-change">
({material.processing_info.quantity_change > 0 ? '+' : ''}
{formatQuantity(material.processing_info.quantity_change)})
</span>
)}
</div>
<div className="table-cell">{material.unit || 'EA'}</div>
<div className="table-cell">
<div className="action-buttons-small">
<button
className="btn-small btn-view"
onClick={() => {/* 상세 보기 로직 */}}
title="상세 정보"
>
👁
</button>
<button
className="btn-small btn-classify"
onClick={() => {/* 수동 분류 로직 */}}
title="수동 분류"
>
🏷
</button>
<button
className="btn-small btn-auto-classify"
onClick={() => {/* 자동 분류 로직 */}}
title="자동 분류"
>
🤖
</button>
</div>
</div>
</div>
))}
</div>
{filteredAndSortedMaterials.length === 0 && (
<div className="empty-state">
<p>조건에 맞는 UNCLASSIFIED 자재가 없습니다.</p>
{materials && materials.length === 0 && (
<p>🎉 모든 자재가 분류되었습니다!</p>
)}
</div>
)}
</div>
{/* 확인 다이얼로그 */}
{showConfirmDialog && (
<ConfirmDialog
title="작업 확인"
message={`선택된 ${selectedMaterials.size}개 자재에 대해 "${actionType}" 작업을 수행하시겠습니까?`}
onConfirm={confirmAction}
onCancel={() => setShowConfirmDialog(false)}
/>
)}
</div>
);
};
export default UnclassifiedRevisionPage;

View File

@@ -1,453 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useRevisionLogic } from '../../hooks/useRevisionLogic';
import { useRevisionComparison } from '../../hooks/useRevisionComparison';
import { useRevisionStatus } from '../../hooks/useRevisionStatus';
import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
import './CategoryRevisionPage.css';
const ValveRevisionPage = ({
jobNo,
fileId,
previousFileId,
onNavigate,
user
}) => {
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [actionType, setActionType] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [sortBy, setSortBy] = useState('description');
const [sortOrder, setSortOrder] = useState('asc');
const [valveTypeFilter, setValveTypeFilter] = useState('all');
const [pressureRatingFilter, setPressureRatingFilter] = useState('all');
const [connectionFilter, setConnectionFilter] = useState('all');
// 리비전 로직 훅
const {
materials,
loading: materialsLoading,
error: materialsError,
processingInfo,
handleMaterialSelection,
handleBulkAction,
refreshMaterials
} = useRevisionLogic(fileId, 'VALVE');
// 리비전 비교 훅
const {
comparisonResult,
loading: comparisonLoading,
error: comparisonError,
performComparison,
getFilteredComparison
} = useRevisionComparison(fileId, previousFileId);
// 리비전 상태 훅
const {
revisionStatus,
loading: statusLoading,
error: statusError,
uploadNewRevision,
navigateToRevision
} = useRevisionStatus(jobNo, fileId);
// 필터링 및 정렬된 자재 목록
const filteredAndSortedMaterials = useMemo(() => {
if (!materials) return [];
let filtered = materials.filter(material => {
const matchesSearch = !searchTerm ||
material.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.valve_type?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' ||
material.processing_info?.display_status === statusFilter;
const matchesValveType = valveTypeFilter === 'all' ||
material.valve_type === valveTypeFilter;
const matchesPressureRating = pressureRatingFilter === 'all' ||
material.pressure_rating === pressureRatingFilter;
const matchesConnection = connectionFilter === 'all' ||
material.connection_method === connectionFilter;
return matchesSearch && matchesStatus && matchesValveType && matchesPressureRating && matchesConnection;
});
// 정렬
filtered.sort((a, b) => {
let aValue = a[sortBy] || '';
let bValue = b[sortBy] || '';
if (typeof aValue === 'string') {
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}
if (sortOrder === 'asc') {
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
} else {
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
}
});
return filtered;
}, [materials, searchTerm, statusFilter, valveTypeFilter, pressureRatingFilter, connectionFilter, sortBy, sortOrder]);
// 고유 값들 추출 (필터 옵션용)
const uniqueValues = useMemo(() => {
if (!materials) return { valveTypes: [], pressureRatings: [], connections: [] };
const valveTypes = [...new Set(materials.map(m => m.valve_type).filter(Boolean))];
const pressureRatings = [...new Set(materials.map(m => m.pressure_rating).filter(Boolean))];
const connections = [...new Set(materials.map(m => m.connection_method).filter(Boolean))];
return { valveTypes, pressureRatings, connections };
}, [materials]);
// 초기 비교 수행
useEffect(() => {
if (fileId && previousFileId && !comparisonResult) {
performComparison('VALVE');
}
}, [fileId, previousFileId, comparisonResult, performComparison]);
// 자재 선택 처리
const handleMaterialSelect = (materialId, isSelected) => {
const newSelected = new Set(selectedMaterials);
if (isSelected) {
newSelected.add(materialId);
} else {
newSelected.delete(materialId);
}
setSelectedMaterials(newSelected);
handleMaterialSelection(materialId, isSelected);
};
// 전체 선택/해제
const handleSelectAll = (isSelected) => {
if (isSelected) {
const allIds = new Set(filteredAndSortedMaterials.map(m => m.id));
setSelectedMaterials(allIds);
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true));
} else {
setSelectedMaterials(new Set());
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false));
}
};
// 액션 실행
const executeAction = async (action) => {
setActionType(action);
setShowConfirmDialog(true);
};
const confirmAction = async () => {
try {
await handleBulkAction(actionType, Array.from(selectedMaterials));
setSelectedMaterials(new Set());
setShowConfirmDialog(false);
await refreshMaterials();
} catch (error) {
console.error('Action failed:', error);
}
};
// 상태별 색상 클래스
const getStatusClass = (status) => {
switch (status) {
case 'REVISION_MATERIAL': return 'status-revision';
case 'INVENTORY_MATERIAL': return 'status-inventory';
case 'DELETED_MATERIAL': return 'status-deleted';
case 'NEW_MATERIAL': return 'status-new';
default: return 'status-normal';
}
};
// 수량 표시 (정수로 변환)
const formatQuantity = (quantity) => {
if (quantity === null || quantity === undefined) return '-';
return Math.round(parseFloat(quantity) || 0).toString();
};
// VALVE 설명 생성 (밸브 타입과 연결 방식 포함)
const generateValveDescription = (material) => {
const parts = [];
if (material.valve_type) parts.push(material.valve_type);
if (material.nominal_size) parts.push(material.nominal_size);
if (material.pressure_rating) parts.push(`${material.pressure_rating}#`);
if (material.connection_method) parts.push(material.connection_method);
if (material.material_grade) parts.push(material.material_grade);
const baseDesc = material.description || material.item_name || 'VALVE';
return parts.length > 0 ? `${baseDesc} (${parts.join(', ')})` : baseDesc;
};
if (materialsLoading || comparisonLoading || statusLoading) {
return <LoadingSpinner message="VALVE 리비전 데이터 로딩 중..." />;
}
const error = materialsError || comparisonError || statusError;
if (error) {
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
}
return (
<div className="category-revision-page">
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button
className="back-button"
onClick={() => onNavigate('enhanced-revision')}
>
리비전 관리로
</button>
<div className="header-center">
<h1>🚰 VALVE 리비전 관리</h1>
<span className="header-subtitle">
밸브 타입과 연결 방식을 고려한 VALVE 자재 리비전 처리
</span>
</div>
</div>
<div className="header-right">
<RevisionStatusIndicator
revisionStatus={revisionStatus}
onUploadRevision={uploadNewRevision}
onNavigateToRevision={navigateToRevision}
/>
</div>
</div>
{/* 컨트롤 섹션 */}
<div className="control-section">
<div className="section-header">
<h3>📊 자재 현황 필터</h3>
{processingInfo && (
<div className="processing-summary">
전체: {processingInfo.total_materials} |
리비전: {processingInfo.by_status.REVISION_MATERIAL || 0} |
재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0} |
삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}
</div>
)}
</div>
<div className="control-grid">
<div className="control-group">
<label>검색:</label>
<input
type="text"
placeholder="자재명, 밸브타입, 도면명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="control-group">
<label>상태 필터:</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">전체</option>
<option value="REVISION_MATERIAL">리비전 자재</option>
<option value="INVENTORY_MATERIAL">재고 자재</option>
<option value="DELETED_MATERIAL">삭제 자재</option>
<option value="NEW_MATERIAL">신규 자재</option>
</select>
</div>
<div className="control-group">
<label>밸브 타입:</label>
<select value={valveTypeFilter} onChange={(e) => setValveTypeFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.valveTypes.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div className="control-group">
<label>압력등급:</label>
<select value={pressureRatingFilter} onChange={(e) => setPressureRatingFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.pressureRatings.map(rating => (
<option key={rating} value={rating}>{rating}#</option>
))}
</select>
</div>
<div className="control-group">
<label>연결 방식:</label>
<select value={connectionFilter} onChange={(e) => setConnectionFilter(e.target.value)}>
<option value="all">전체</option>
{uniqueValues.connections.map(conn => (
<option key={conn} value={conn}>{conn}</option>
))}
</select>
</div>
<div className="control-group">
<label>정렬:</label>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="description">자재명</option>
<option value="valve_type">밸브 타입</option>
<option value="nominal_size">크기</option>
<option value="pressure_rating">압력등급</option>
<option value="quantity">수량</option>
</select>
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
<option value="asc">오름차순</option>
<option value="desc">내림차순</option>
</select>
</div>
</div>
{/* 선택된 자재 액션 */}
{selectedMaterials.size > 0 && (
<div className="selected-actions">
<span className="selected-count">
{selectedMaterials.size} 선택됨
</span>
<div className="action-buttons">
<button
className="btn-action btn-purchase"
onClick={() => executeAction('request_purchase')}
>
구매 신청
</button>
<button
className="btn-action btn-inventory"
onClick={() => executeAction('mark_inventory')}
>
재고 처리
</button>
<button
className="btn-action btn-delete"
onClick={() => executeAction('mark_deleted')}
>
삭제 처리
</button>
</div>
</div>
)}
</div>
{/* 자재 테이블 */}
<div className="materials-table-container">
<div className="table-header">
<div className="header-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.size === filteredAndSortedMaterials.length && filteredAndSortedMaterials.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</div>
<div className="header-cell">상태</div>
<div className="header-cell">자재명</div>
<div className="header-cell">밸브 타입</div>
<div className="header-cell">크기</div>
<div className="header-cell">압력등급</div>
<div className="header-cell">연결방식</div>
<div className="header-cell">수량</div>
<div className="header-cell">단위</div>
<div className="header-cell">액션</div>
</div>
<div className="table-body">
{filteredAndSortedMaterials.map((material) => (
<div
key={material.id}
className={`table-row ${getStatusClass(material.processing_info?.display_status)}`}
>
<div className="table-cell checkbox-cell">
<input
type="checkbox"
checked={selectedMaterials.has(material.id)}
onChange={(e) => handleMaterialSelect(material.id, e.target.checked)}
/>
</div>
<div className="table-cell">
<span className={`status-badge ${getStatusClass(material.processing_info?.display_status)}`}>
{material.processing_info?.display_status || 'NORMAL'}
</span>
</div>
<div className="table-cell">
<div className="material-info">
<div className="material-name">{generateValveDescription(material)}</div>
{material.processing_info?.notes && (
<div className="material-notes">{material.processing_info.notes}</div>
)}
</div>
</div>
<div className="table-cell">{material.valve_type || '-'}</div>
<div className="table-cell">{material.nominal_size || '-'}</div>
<div className="table-cell">{material.pressure_rating ? `${material.pressure_rating}#` : '-'}</div>
<div className="table-cell">{material.connection_method || '-'}</div>
<div className="table-cell quantity-cell">
<span className="quantity-value">
{formatQuantity(material.quantity)}
</span>
{material.processing_info?.quantity_change && (
<span className="quantity-change">
({material.processing_info.quantity_change > 0 ? '+' : ''}
{formatQuantity(material.processing_info.quantity_change)})
</span>
)}
</div>
<div className="table-cell">{material.unit || 'EA'}</div>
<div className="table-cell">
<div className="action-buttons-small">
<button
className="btn-small btn-view"
onClick={() => {/* 상세 보기 로직 */}}
title="상세 정보"
>
👁
</button>
<button
className="btn-small btn-edit"
onClick={() => {/* 편집 로직 */}}
title="편집"
>
</button>
<button
className="btn-small btn-test"
onClick={() => {/* 밸브 테스트 로직 */}}
title="밸브 테스트"
>
🧪
</button>
</div>
</div>
</div>
))}
</div>
{filteredAndSortedMaterials.length === 0 && (
<div className="empty-state">
<p>조건에 맞는 VALVE 자재가 없습니다.</p>
</div>
)}
</div>
{/* 확인 다이얼로그 */}
{showConfirmDialog && (
<ConfirmDialog
title="작업 확인"
message={`선택된 ${selectedMaterials.size}개 자재에 대해 "${actionType}" 작업을 수행하시겠습니까?`}
onConfirm={confirmAction}
onCancel={() => setShowConfirmDialog(false)}
/>
)}
</div>
);
};
export default ValveRevisionPage;