🔧 완전한 스키마 자동화 시스템 구축
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

 주요 기능:
- 완전한 데이터베이스 스키마 분석 및 자동 마이그레이션 시스템
- 44개 테이블 완전 지원 (운영 서버 43개 + 1개 추가)
- 누락된 테이블/컬럼 자동 감지 및 생성

🔧 해결된 스키마 문제:
- users.status 컬럼 누락 → 자동 추가
- files 테이블 4개 컬럼 누락 → 자동 추가
- materials 테이블 22개 컬럼 누락 → 자동 추가
- support_details, purchase_requests, purchase_request_items 테이블 누락 → 자동 생성
- material_purchase_tracking.description, purchase_status 컬럼 누락 → 자동 추가

🚀 자동화 도구:
- schema_analyzer.py: 코드와 DB 스키마 비교 분석
- auto_migrator.py: 자동 마이그레이션 실행
- docker_migrator.py: Docker 환경용 간편 마이그레이션
- schema_monitor.py: 실시간 스키마 모니터링

📋 리비전 관리 시스템:
- 8개 카테고리별 리비전 페이지 구현
- PIPE Cutting Plan 관리 시스템
- PIPE Issue Management 시스템
- 완전한 리비전 비교 및 추적 기능

🎯 사용법:
docker exec tk-mp-backend python3 scripts/docker_migrator.py

앞으로 스키마 문제가 발생하면 위 명령 하나로 자동 해결!
This commit is contained in:
Hyungi Ahn
2025-10-21 10:34:45 +09:00
parent 9d7165bbf9
commit 8f42a1054e
55 changed files with 22443 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
/**
* React Error Boundary 컴포넌트
*
* 애플리케이션에서 발생하는 JavaScript 오류를 캐치하고 처리
*/
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
// 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트합니다.
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// 에러 로깅
console.error('ErrorBoundary caught an error:', error, errorInfo);
this.setState({
error: error,
errorInfo: errorInfo
});
}
render() {
if (this.state.hasError) {
// 폴백 UI를 커스터마이징할 수 있습니다.
return (
<div style={{
padding: '20px',
margin: '20px',
border: '1px solid #ff6b6b',
borderRadius: '8px',
backgroundColor: '#fff5f5',
textAlign: 'center'
}}>
<h2 style={{ color: '#d63031', marginBottom: '15px' }}>
오류가 발생했습니다
</h2>
<p style={{ color: '#636e72', marginBottom: '20px' }}>
예상치 못한 오류가 발생했습니다. 페이지를 새로고침하거나 관리자에게 문의해주세요.
</p>
{process.env.NODE_ENV === 'development' && this.state.error && (
<details style={{
marginTop: '20px',
textAlign: 'left',
backgroundColor: '#f8f9fa',
padding: '15px',
borderRadius: '4px',
border: '1px solid #dee2e6'
}}>
<summary style={{
cursor: 'pointer',
fontWeight: 'bold',
color: '#495057',
marginBottom: '10px'
}}>
개발자 정보 (클릭하여 펼치기)
</summary>
<div style={{
fontFamily: 'monospace',
fontSize: '12px',
color: '#6c757d',
whiteSpace: 'pre-wrap'
}}>
<strong>Error:</strong> {this.state.error && this.state.error.toString()}
<br /><br />
<strong>Component Stack:</strong>
{this.state.errorInfo.componentStack}
</div>
</details>
)}
<div style={{ marginTop: '20px' }}>
<button
onClick={() => window.location.reload()}
style={{
backgroundColor: '#0984e3',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '4px',
cursor: 'pointer',
marginRight: '10px'
}}
>
🔄 페이지 새로고침
</button>
<button
onClick={() => window.history.back()}
style={{
backgroundColor: '#636e72',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '4px',
cursor: 'pointer'
}}
>
이전 페이지
</button>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,260 @@
import React, { useState } from 'react';
import { api } from '../../api';
import { LoadingSpinner, ErrorMessage } from '../common';
const FittingRevisionManager = ({
currentFileId,
previousFileId,
jobNo,
onRevisionComplete,
materials = []
}) => {
const [fittingComparison, setFittingComparison] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// FITTING 자재만 필터링
const fittingMaterials = materials.filter(m => m.classified_category === 'FITTING');
const handleFittingRevisionCompare = async () => {
setLoading(true);
setError('');
try {
const params = {
job_no: jobNo,
current_file_id: currentFileId,
category_filter: 'FITTING',
save_comparison: true
};
if (previousFileId) {
params.previous_file_id = previousFileId;
}
const response = await api.post('/enhanced-revision/compare-revisions', null, { params });
setFittingComparison(response.data.data);
if (onRevisionComplete) {
onRevisionComplete('FITTING', response.data.data);
}
} catch (err) {
setError('FITTING 리비전 비교 실패: ' + err.message);
} finally {
setLoading(false);
}
};
const renderFittingChanges = () => {
if (!fittingComparison?.changes) return null;
const changes = fittingComparison.changes;
return (
<div className="fitting-changes">
<h4>🔩 FITTING 자재 변경사항</h4>
{/* 구매 완료 FITTING 변경사항 */}
{changes.purchased_materials?.additional_purchase_needed?.length > 0 && (
<div className="change-section purchased-fittings">
<h5>📈 추가 구매 필요 (구매완료 FITTING)</h5>
<div className="fitting-list">
{changes.purchased_materials.additional_purchase_needed
.filter(item => item.material.classified_category === 'FITTING')
.map((item, idx) => (
<div key={idx} className="fitting-item additional-purchase">
<div className="fitting-info">
<span className="fitting-desc">
{item.material.original_description}
</span>
<span className="fitting-spec">
{item.material.material_grade} {item.material.main_nom}
</span>
</div>
<div className="quantity-change">
<span className="previous">이전: {item.previous_quantity}</span>
<span className="arrow"></span>
<span className="current">현재: {item.current_quantity}</span>
<span className="additional">추가: +{item.additional_needed}</span>
</div>
<div className="action-required">
추가 구매 필요
</div>
</div>
))}
</div>
</div>
)}
{/* 잉여 재고 FITTING */}
{changes.purchased_materials?.excess_inventory?.length > 0 && (
<div className="change-section excess-fittings">
<h5>📉 잉여 재고 (구매완료 FITTING)</h5>
<div className="fitting-list">
{changes.purchased_materials.excess_inventory
.filter(item => item.material.classified_category === 'FITTING')
.map((item, idx) => (
<div key={idx} className="fitting-item excess-inventory">
<div className="fitting-info">
<span className="fitting-desc">
{item.material.original_description}
</span>
<span className="fitting-spec">
{item.material.material_grade} {item.material.main_nom}
</span>
</div>
<div className="quantity-change">
<span className="previous">이전: {item.previous_quantity}</span>
<span className="arrow"></span>
<span className="current">현재: {item.current_quantity}</span>
<span className="excess">잉여: -{item.excess_quantity}</span>
</div>
<div className="action-required">
📦 재고로 보관
</div>
</div>
))}
</div>
</div>
)}
{/* 구매 미완료 FITTING 변경사항 */}
{changes.unpurchased_materials?.quantity_updated?.length > 0 && (
<div className="change-section unpurchased-fittings">
<h5>📊 수량 변경 (구매미완료 FITTING)</h5>
<div className="fitting-list">
{changes.unpurchased_materials.quantity_updated
.filter(item => item.material.classified_category === 'FITTING')
.map((item, idx) => (
<div key={idx} className="fitting-item quantity-updated">
<div className="fitting-info">
<span className="fitting-desc">
{item.material.original_description}
</span>
<span className="fitting-spec">
{item.material.material_grade} {item.material.main_nom}
</span>
</div>
<div className="quantity-change">
<span className="previous">이전: {item.previous_quantity}</span>
<span className="arrow"></span>
<span className="current">현재: {item.current_quantity}</span>
</div>
<div className="action-required">
구매 수량 업데이트
</div>
</div>
))}
</div>
</div>
)}
{/* 신규 FITTING */}
{changes.new_materials?.filter(item => item.material.classified_category === 'FITTING').length > 0 && (
<div className="change-section new-fittings">
<h5> 신규 FITTING</h5>
<div className="fitting-list">
{changes.new_materials
.filter(item => item.material.classified_category === 'FITTING')
.map((item, idx) => (
<div key={idx} className="fitting-item new-material">
<div className="fitting-info">
<span className="fitting-desc">
{item.material.original_description}
</span>
<span className="fitting-spec">
{item.material.material_grade} {item.material.main_nom}
</span>
</div>
<div className="quantity-info">
<span className="quantity">수량: {item.material.quantity}</span>
</div>
<div className="action-required">
🆕 신규 구매 필요
</div>
</div>
))}
</div>
</div>
)}
{/* 삭제된 FITTING */}
{changes.deleted_materials?.filter(item => item.material.classified_category === 'FITTING').length > 0 && (
<div className="change-section deleted-fittings">
<h5> 삭제된 FITTING</h5>
<div className="fitting-list">
{changes.deleted_materials
.filter(item => item.material.classified_category === 'FITTING')
.map((item, idx) => (
<div key={idx} className="fitting-item deleted-material">
<div className="fitting-info">
<span className="fitting-desc">
{item.material.original_description}
</span>
<span className="fitting-spec">
{item.material.material_grade} {item.material.main_nom}
</span>
</div>
<div className="quantity-info">
<span className="quantity">이전 수량: {item.material.quantity}</span>
</div>
<div className="action-required">
🗑 리비전에서 제거됨
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};
const renderFittingSummary = () => {
const totalFittings = fittingMaterials.length;
const purchasedFittings = fittingMaterials.filter(m => m.purchase_confirmed).length;
const pendingFittings = totalFittings - purchasedFittings;
return (
<div className="fitting-summary">
<h4>🔩 FITTING 요약</h4>
<div className="summary-stats">
<div className="stat-card">
<span className="stat-label"> FITTING</span>
<span className="stat-value">{totalFittings}</span>
</div>
<div className="stat-card">
<span className="stat-label">구매완료</span>
<span className="stat-value">{purchasedFittings}</span>
</div>
<div className="stat-card">
<span className="stat-label">구매대기</span>
<span className="stat-value">{pendingFittings}</span>
</div>
</div>
</div>
);
};
return (
<div className="fitting-revision-manager">
{error && <ErrorMessage message={error} onClose={() => setError('')} />}
<div className="fitting-controls">
<button
className="btn-compare-fitting"
onClick={handleFittingRevisionCompare}
disabled={loading || !currentFileId}
>
{loading ? <LoadingSpinner size="small" /> : '🔍 FITTING 리비전 비교'}
</button>
</div>
{renderFittingSummary()}
{renderFittingChanges()}
</div>
);
};
export default FittingRevisionManager;

View File

@@ -0,0 +1,270 @@
import React, { useState, useEffect } from 'react';
import { api } from '../../api';
import { LoadingSpinner, ErrorMessage } from '../common';
const PipeRevisionManager = ({
currentFileId,
previousFileId,
jobNo,
onRevisionComplete,
materials = []
}) => {
const [pipeComparison, setPipeComparison] = useState(null);
const [pipeLengthSummary, setPipeLengthSummary] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// PIPE 자재만 필터링
const pipeMaterials = materials.filter(m => m.classified_category === 'PIPE');
useEffect(() => {
if (currentFileId) {
loadPipeLengthSummary();
}
}, [currentFileId]);
const loadPipeLengthSummary = async () => {
try {
const response = await api.get(`/enhanced-revision/pipe-length-summary/${currentFileId}`);
setPipeLengthSummary(response.data.data);
} catch (err) {
console.error('PIPE 길이 요약 조회 실패:', err);
}
};
const handlePipeRevisionCompare = async () => {
setLoading(true);
setError('');
try {
const params = {
job_no: jobNo,
current_file_id: currentFileId,
category_filter: 'PIPE',
save_comparison: true
};
if (previousFileId) {
params.previous_file_id = previousFileId;
}
const response = await api.post('/enhanced-revision/compare-revisions', null, { params });
setPipeComparison(response.data.data);
if (onRevisionComplete) {
onRevisionComplete('PIPE', response.data.data);
}
} catch (err) {
setError('PIPE 리비전 비교 실패: ' + err.message);
} finally {
setLoading(false);
}
};
const handleRecalculatePipeLengths = async () => {
setLoading(true);
try {
const response = await api.post(`/enhanced-revision/recalculate-pipe-lengths/${currentFileId}`);
if (response.data.success) {
alert(`PIPE 자재 ${response.data.data.updated_count}개의 길이를 재계산했습니다.`);
loadPipeLengthSummary();
}
} catch (err) {
setError('PIPE 길이 재계산 실패: ' + err.message);
} finally {
setLoading(false);
}
};
const renderPipeChanges = () => {
if (!pipeComparison?.changes) return null;
const changes = pipeComparison.changes;
return (
<div className="pipe-changes">
<h4>🔧 PIPE 자재 변경사항</h4>
{/* 구매 완료 PIPE 변경사항 */}
{changes.purchased_materials?.additional_purchase_needed?.length > 0 && (
<div className="change-section purchased-pipes">
<h5>📈 추가 구매 필요 (구매완료 PIPE)</h5>
<div className="pipe-list">
{changes.purchased_materials.additional_purchase_needed
.filter(item => item.material.classified_category === 'PIPE')
.map((item, idx) => (
<div key={idx} className="pipe-item additional-purchase">
<div className="pipe-info">
<span className="drawing-line">
{item.material.drawing_name} - {item.material.line_no}
</span>
<span className="pipe-spec">
{item.material.material_grade} {item.material.schedule} {item.material.main_nom}
</span>
</div>
<div className="length-change">
<span className="previous">이전: {item.previous_quantity}m</span>
<span className="arrow"></span>
<span className="current">현재: {item.current_quantity}m</span>
<span className="additional">추가: +{item.additional_needed}m</span>
</div>
<div className="action-required">
추가 구매 필요
</div>
</div>
))}
</div>
</div>
)}
{/* 잉여 재고 PIPE */}
{changes.purchased_materials?.excess_inventory?.length > 0 && (
<div className="change-section excess-pipes">
<h5>📉 잉여 재고 (구매완료 PIPE)</h5>
<div className="pipe-list">
{changes.purchased_materials.excess_inventory
.filter(item => item.material.classified_category === 'PIPE')
.map((item, idx) => (
<div key={idx} className="pipe-item excess-inventory">
<div className="pipe-info">
<span className="drawing-line">
{item.material.drawing_name} - {item.material.line_no}
</span>
<span className="pipe-spec">
{item.material.material_grade} {item.material.schedule} {item.material.main_nom}
</span>
</div>
<div className="length-change">
<span className="previous">이전: {item.previous_quantity}m</span>
<span className="arrow"></span>
<span className="current">현재: {item.current_quantity}m</span>
<span className="excess">잉여: -{item.excess_quantity}m</span>
</div>
<div className="action-required">
📦 재고로 보관
</div>
</div>
))}
</div>
</div>
)}
{/* 구매 미완료 PIPE 변경사항 */}
{changes.unpurchased_materials?.quantity_updated?.length > 0 && (
<div className="change-section unpurchased-pipes">
<h5>📊 수량 변경 (구매미완료 PIPE)</h5>
<div className="pipe-list">
{changes.unpurchased_materials.quantity_updated
.filter(item => item.material.classified_category === 'PIPE')
.map((item, idx) => (
<div key={idx} className="pipe-item quantity-updated">
<div className="pipe-info">
<span className="drawing-line">
{item.material.drawing_name} - {item.material.line_no}
</span>
<span className="pipe-spec">
{item.material.material_grade} {item.material.schedule} {item.material.main_nom}
</span>
</div>
<div className="length-change">
<span className="previous">이전: {item.previous_quantity}m</span>
<span className="arrow"></span>
<span className="current">현재: {item.current_quantity}m</span>
</div>
<div className="action-required">
구매 수량 업데이트
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};
const renderPipeLengthSummary = () => {
if (!pipeLengthSummary) return null;
return (
<div className="pipe-length-summary">
<div className="summary-header">
<h4>🔧 PIPE 길이 요약</h4>
<button
className="btn-recalculate"
onClick={handleRecalculatePipeLengths}
disabled={loading}
>
🔄 길이 재계산
</button>
</div>
<div className="pipe-stats">
<div className="stat-card">
<span className="stat-label"> 라인</span>
<span className="stat-value">{pipeLengthSummary.total_lines}</span>
</div>
<div className="stat-card">
<span className="stat-label"> 길이</span>
<span className="stat-value">{pipeLengthSummary.total_length?.toFixed(2)}m</span>
</div>
<div className="stat-card">
<span className="stat-label">현재 자재 </span>
<span className="stat-value">{pipeMaterials.length}</span>
</div>
</div>
<div className="pipe-lines">
{pipeLengthSummary.pipe_lines?.slice(0, 5).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>
))}
{pipeLengthSummary.pipe_lines?.length > 5 && (
<div className="more-lines">
... {pipeLengthSummary.pipe_lines.length - 5} 라인
</div>
)}
</div>
</div>
);
};
return (
<div className="pipe-revision-manager">
{error && <ErrorMessage message={error} onClose={() => setError('')} />}
<div className="pipe-controls">
<button
className="btn-compare-pipe"
onClick={handlePipeRevisionCompare}
disabled={loading || !currentFileId}
>
{loading ? <LoadingSpinner size="small" /> : '🔍 PIPE 리비전 비교'}
</button>
</div>
{renderPipeLengthSummary()}
{renderPipeChanges()}
</div>
);
};
export default PipeRevisionManager;

View File

@@ -0,0 +1,663 @@
/* 리비전 비교 뷰 컴포넌트 스타일 */
.revision-comparison-view {
background: #f8f9fa;
min-height: 100vh;
padding: 20px;
}
/* 로딩 및 빈 상태 */
.revision-comparison-loading,
.revision-comparison-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
background: white;
border-radius: 8px;
border: 1px solid #e5e7eb;
}
.revision-comparison-loading p,
.revision-comparison-empty p {
margin-top: 16px;
color: #6b7280;
font-size: 14px;
}
/* 헤더 */
.comparison-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
padding: 20px;
background: white;
border-radius: 8px;
border: 1px solid #e5e7eb;
}
.header-info h3 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
color: #1f2937;
}
.header-info p {
margin: 0;
color: #6b7280;
font-size: 14px;
}
.comparison-date {
font-size: 12px;
color: #9ca3af;
margin-left: 8px;
}
.header-actions {
display: flex;
gap: 12px;
}
.btn-export,
.btn-refresh {
padding: 8px 16px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
color: #374151;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-export:hover,
.btn-refresh:hover {
background: #f9fafb;
border-color: #9ca3af;
}
.btn-export:disabled,
.btn-refresh:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 요약 카드 */
.comparison-summary-card {
margin-bottom: 24px;
}
/* 필터 섹션 */
.comparison-filters {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
background: white;
border-radius: 8px;
border: 1px solid #e5e7eb;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-group label {
font-size: 14px;
font-weight: 500;
color: #374151;
white-space: nowrap;
}
.filter-group select,
.filter-group input[type="text"] {
padding: 6px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
background: white;
min-width: 120px;
}
.filter-group select:focus,
.filter-group input[type="text"]:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.filter-group input[type="checkbox"] {
margin-right: 6px;
}
.btn-reset-filters {
padding: 6px 12px;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
color: #374151;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-reset-filters:hover {
background: #e5e7eb;
}
/* 필터링된 요약 */
.filtered-summary {
padding: 12px 20px;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 6px;
margin-bottom: 20px;
font-size: 14px;
color: #0369a1;
}
.filter-note {
color: #6b7280;
margin-left: 8px;
}
/* 탭 네비게이션 */
.comparison-tabs {
display: flex;
background: white;
border-radius: 8px 8px 0 0;
border: 1px solid #e5e7eb;
border-bottom: none;
}
.tab-button {
flex: 1;
padding: 12px 20px;
background: none;
border: none;
font-size: 14px;
font-weight: 500;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 2px solid transparent;
}
.tab-button:first-child {
border-radius: 8px 0 0 0;
}
.tab-button:last-child {
border-radius: 0 8px 0 0;
}
.tab-button:hover {
background: #f9fafb;
color: #374151;
}
.tab-button.active {
background: white;
color: #6366f1;
border-bottom-color: #6366f1;
}
/* 탭 콘텐츠 */
.comparison-content {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0 0 8px 8px;
min-height: 400px;
}
/* 요약 탭 */
.comparison-summary-tab {
padding: 24px;
}
.summary-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.stat-card {
padding: 20px;
background: #f9fafb;
border-radius: 8px;
border: 1px solid #f3f4f6;
text-align: center;
}
.stat-card h4 {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 500;
color: #6b7280;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #1f2937;
}
.change-previews {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.change-preview {
padding: 16px;
background: #f9fafb;
border-radius: 8px;
border: 1px solid #f3f4f6;
}
.change-preview h4 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #374151;
}
.preview-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.preview-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
background: white;
border-radius: 6px;
font-size: 12px;
}
.material-desc {
flex: 1;
color: #374151;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-more {
padding: 8px;
text-align: center;
color: #6b7280;
font-size: 12px;
font-style: italic;
}
/* 상세 변경사항 탭 */
.comparison-details-tab {
padding: 24px;
}
.change-type-section {
margin-bottom: 32px;
}
.change-type-header {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
color: #1f2937;
padding-bottom: 8px;
border-bottom: 1px solid #e5e7eb;
}
.change-items {
display: flex;
flex-direction: column;
gap: 12px;
}
.change-item-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: white;
overflow: hidden;
transition: all 0.2s ease;
}
.change-item-card:hover {
border-color: #d1d5db;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.change-item-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.change-item-header:hover {
background: #f9fafb;
}
.item-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.item-actions {
display: flex;
align-items: center;
gap: 12px;
}
.btn-select {
padding: 4px 12px;
background: #6366f1;
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-select:hover {
background: #5558e3;
}
.expand-icon {
color: #9ca3af;
font-size: 12px;
transition: transform 0.2s ease;
}
.change-item-details {
padding: 16px;
border-top: 1px solid #f3f4f6;
background: #f9fafb;
}
/* 자재별 보기 탭 */
.comparison-materials-tab {
padding: 24px;
}
.materials-table {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.table-header {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 100px;
gap: 16px;
padding: 12px 16px;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
font-weight: 600;
font-size: 14px;
color: #374151;
}
.table-body {
max-height: 600px;
overflow-y: auto;
}
.table-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 100px;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid #f3f4f6;
font-size: 14px;
color: #374151;
transition: background-color 0.2s ease;
}
.table-row:hover {
background: #f9fafb;
}
.table-row:last-child {
border-bottom: none;
}
.btn-select-small {
padding: 2px 8px;
background: #6366f1;
color: white;
border: none;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-select-small:hover {
background: #5558e3;
}
/* 자재 변경 상세 정보 */
.material-change-details {
background: white;
border-radius: 6px;
border: 1px solid #e5e7eb;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
padding: 16px;
}
.detail-section h5 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #374151;
padding-bottom: 8px;
border-bottom: 1px solid #f3f4f6;
}
.detail-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
font-size: 13px;
}
.detail-item .label {
font-weight: 500;
color: #6b7280;
min-width: 80px;
}
.detail-item .value {
color: #374151;
text-align: right;
flex: 1;
}
.change-value {
display: flex;
align-items: center;
gap: 6px;
}
.old-value {
color: #dc2626;
text-decoration: line-through;
}
.arrow {
color: #6b7280;
font-size: 12px;
}
.new-value {
color: #059669;
font-weight: 500;
}
.change-amount {
color: #6b7280;
font-size: 11px;
}
/* 반응형 디자인 */
@media (max-width: 1200px) {
.comparison-filters {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.filter-group {
justify-content: space-between;
}
.summary-stats {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.change-previews {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.revision-comparison-view {
padding: 16px;
}
.comparison-header {
flex-direction: column;
gap: 16px;
align-items: stretch;
}
.header-actions {
justify-content: stretch;
}
.btn-export,
.btn-refresh {
flex: 1;
}
.comparison-tabs {
flex-direction: column;
}
.tab-button {
border-radius: 0;
border-bottom: 1px solid #e5e7eb;
}
.tab-button:first-child {
border-radius: 8px 8px 0 0;
}
.tab-button:last-child {
border-radius: 0;
border-bottom: none;
}
.table-header,
.table-row {
grid-template-columns: 1fr;
gap: 8px;
}
.table-header span,
.table-row span {
padding: 4px 0;
}
.table-header span:before,
.table-row span:before {
content: attr(data-label) ': ';
font-weight: 600;
color: #6b7280;
display: inline-block;
min-width: 80px;
}
.detail-grid {
grid-template-columns: 1fr;
}
.detail-item {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.detail-item .value {
text-align: left;
}
}
/* 애니메이션 */
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.change-item-details {
animation: slideDown 0.2s ease-out;
}
.comparison-content {
animation: slideDown 0.3s ease-out;
}
/* 스크롤바 스타일 */
.table-body::-webkit-scrollbar {
width: 6px;
}
.table-body::-webkit-scrollbar-track {
background: #f3f4f6;
}
.table-body::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
}
.table-body::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}

View File

@@ -0,0 +1,608 @@
import React, { useState, useMemo } from 'react';
import { useRevisionComparison, useRevisionFiltering } from '../../hooks/useRevisionComparison';
import RevisionStatusIndicator, { RevisionChangeSummary } from './RevisionStatusIndicator';
import { LoadingSpinner, ErrorMessage } from '../common';
import './RevisionComparisonView.css';
/**
* 리비전 비교 뷰 컴포넌트
*/
const RevisionComparisonView = ({
currentFileId,
previousFileId,
category = null,
onMaterialSelect = null,
className = ''
}) => {
const [selectedTab, setSelectedTab] = useState('summary');
const [expandedItems, setExpandedItems] = useState(new Set());
const {
comparisonResult,
loading,
error,
compareRevisions,
getComparisonSummary,
getChangesByType,
exportComparisonReport
} = useRevisionComparison();
const {
filters,
updateFilter,
getFilteredChanges,
getFilterSummary,
resetFilters
} = useRevisionFiltering(comparisonResult);
// 초기 비교 실행
React.useEffect(() => {
if (currentFileId && previousFileId) {
compareRevisions(currentFileId, previousFileId, category);
}
}, [currentFileId, previousFileId, category, compareRevisions]);
const summary = useMemo(() => {
return getComparisonSummary(comparisonResult);
}, [comparisonResult, getComparisonSummary]);
const filteredChanges = useMemo(() => {
return getFilteredChanges();
}, [getFilteredChanges]);
const filteredSummary = useMemo(() => {
return getFilterSummary();
}, [getFilterSummary]);
const toggleItemExpansion = (itemId) => {
setExpandedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(itemId)) {
newSet.delete(itemId);
} else {
newSet.add(itemId);
}
return newSet;
});
};
const handleExport = async (format) => {
await exportComparisonReport(comparisonResult, format);
};
if (loading) {
return (
<div className="revision-comparison-loading">
<LoadingSpinner />
<p>리비전을 비교하고 있습니다...</p>
</div>
);
}
if (error) {
return <ErrorMessage message={error} />;
}
if (!comparisonResult) {
return (
<div className="revision-comparison-empty">
<p>비교할 리비전을 선택해주세요.</p>
</div>
);
}
return (
<div className={`revision-comparison-view ${className}`}>
{/* 헤더 */}
<div className="comparison-header">
<div className="header-info">
<h3>리비전 비교 결과</h3>
<p>
{category ? `${category} 카테고리` : '전체'} 자재 비교
{comparisonResult.comparison_date && (
<span className="comparison-date">
({new Date(comparisonResult.comparison_date).toLocaleString()})
</span>
)}
</p>
</div>
<div className="header-actions">
<button
className="btn-export"
onClick={() => handleExport('excel')}
disabled={loading}
>
📊 Excel 내보내기
</button>
<button
className="btn-refresh"
onClick={() => compareRevisions(currentFileId, previousFileId, category)}
disabled={loading}
>
🔄 새로고침
</button>
</div>
</div>
{/* 요약 카드 */}
{summary && (
<RevisionChangeSummary
changes={comparisonResult.changes}
className="comparison-summary-card"
/>
)}
{/* 필터 섹션 */}
<div className="comparison-filters">
<div className="filter-group">
<label>변경 타입:</label>
<select
value={filters.changeType}
onChange={(e) => updateFilter('changeType', e.target.value)}
>
<option value="all">전체</option>
<option value="added">추가됨</option>
<option value="modified">수정됨</option>
<option value="removed">제거됨</option>
<option value="unchanged">변경없음</option>
</select>
</div>
{!category && (
<div className="filter-group">
<label>카테고리:</label>
<select
value={filters.category}
onChange={(e) => updateFilter('category', e.target.value)}
>
<option value="all">전체</option>
<option value="FITTING">FITTING</option>
<option value="FLANGE">FLANGE</option>
<option value="VALVE">VALVE</option>
<option value="GASKET">GASKET</option>
<option value="BOLT">BOLT</option>
<option value="SUPPORT">SUPPORT</option>
<option value="SPECIAL">SPECIAL</option>
</select>
</div>
)}
<div className="filter-group">
<label>검색:</label>
<input
type="text"
placeholder="자재명 검색..."
value={filters.searchTerm}
onChange={(e) => updateFilter('searchTerm', e.target.value)}
/>
</div>
<div className="filter-group">
<label>
<input
type="checkbox"
checked={filters.showOnlySignificant}
onChange={(e) => updateFilter('showOnlySignificant', e.target.checked)}
/>
중요한 변경사항만
</label>
</div>
<button className="btn-reset-filters" onClick={resetFilters}>
필터 초기화
</button>
</div>
{/* 필터링된 요약 */}
{filteredSummary && (
<div className="filtered-summary">
<span>필터 결과: {filteredSummary.total}</span>
{filteredSummary.total !== summary?.totalChanges && (
<span className="filter-note">
(전체 {summary.totalChanges} )
</span>
)}
</div>
)}
{/* 탭 네비게이션 */}
<div className="comparison-tabs">
<button
className={`tab-button ${selectedTab === 'summary' ? 'active' : ''}`}
onClick={() => setSelectedTab('summary')}
>
요약
</button>
<button
className={`tab-button ${selectedTab === 'details' ? 'active' : ''}`}
onClick={() => setSelectedTab('details')}
>
상세 변경사항
</button>
<button
className={`tab-button ${selectedTab === 'materials' ? 'active' : ''}`}
onClick={() => setSelectedTab('materials')}
>
자재별 보기
</button>
</div>
{/* 탭 콘텐츠 */}
<div className="comparison-content">
{selectedTab === 'summary' && (
<ComparisonSummaryTab
summary={summary}
filteredChanges={filteredChanges}
/>
)}
{selectedTab === 'details' && (
<ComparisonDetailsTab
changes={filteredChanges || comparisonResult.changes}
expandedItems={expandedItems}
onToggleExpansion={toggleItemExpansion}
onMaterialSelect={onMaterialSelect}
/>
)}
{selectedTab === 'materials' && (
<ComparisonMaterialsTab
changes={filteredChanges || comparisonResult.changes}
onMaterialSelect={onMaterialSelect}
/>
)}
</div>
</div>
);
};
/**
* 요약 탭 컴포넌트
*/
const ComparisonSummaryTab = ({ summary, filteredChanges }) => {
if (!summary) return null;
const getChangeTypeStats = () => {
if (!filteredChanges) return null;
return Object.entries(filteredChanges).map(([changeType, items]) => ({
type: changeType,
count: items.length,
items: items.slice(0, 5) // 상위 5개만 표시
}));
};
const changeStats = getChangeTypeStats();
return (
<div className="comparison-summary-tab">
<div className="summary-stats">
<div className="stat-card">
<h4>전체 변경률</h4>
<div className="stat-value">
{summary.changePercentage?.toFixed(1)}%
</div>
</div>
<div className="stat-card">
<h4>안정성</h4>
<div className="stat-value">
{(100 - (summary.changePercentage || 0)).toFixed(1)}%
</div>
</div>
<div className="stat-card">
<h4> 변경사항</h4>
<div className="stat-value">
{summary.totalChanges}
</div>
</div>
</div>
{changeStats && (
<div className="change-previews">
{changeStats.map(stat => (
stat.count > 0 && (
<div key={stat.type} className="change-preview">
<h4>
{getChangeTypeLabel(stat.type)} ({stat.count})
</h4>
<div className="preview-items">
{stat.items.map((item, idx) => (
<div key={idx} className="preview-item">
<span className="material-desc">
{getMaterialDescription(item)}
</span>
<RevisionStatusIndicator
status={getStatusFromChangeType(stat.type)}
size="small"
/>
</div>
))}
{stat.count > 5 && (
<div className="preview-more">
{stat.count - 5} ...
</div>
)}
</div>
</div>
)
))}
</div>
)}
</div>
);
};
/**
* 상세 변경사항 탭 컴포넌트
*/
const ComparisonDetailsTab = ({
changes,
expandedItems,
onToggleExpansion,
onMaterialSelect
}) => {
if (!changes) return null;
return (
<div className="comparison-details-tab">
{Object.entries(changes).map(([changeType, items]) => (
items.length > 0 && (
<div key={changeType} className="change-type-section">
<h4 className="change-type-header">
{getChangeTypeLabel(changeType)} ({items.length})
</h4>
<div className="change-items">
{items.map((item, idx) => {
const itemId = `${changeType}-${idx}`;
const isExpanded = expandedItems.has(itemId);
return (
<div key={idx} className="change-item-card">
<div
className="change-item-header"
onClick={() => onToggleExpansion(itemId)}
>
<div className="item-info">
<span className="material-desc">
{getMaterialDescription(item)}
</span>
<RevisionStatusIndicator
status={getStatusFromChangeType(changeType)}
size="small"
/>
</div>
<div className="item-actions">
{onMaterialSelect && (
<button
className="btn-select"
onClick={(e) => {
e.stopPropagation();
onMaterialSelect(item);
}}
>
선택
</button>
)}
<span className="expand-icon">
{isExpanded ? '▼' : '▶'}
</span>
</div>
</div>
{isExpanded && (
<div className="change-item-details">
<MaterialChangeDetails
item={item}
changeType={changeType}
/>
</div>
)}
</div>
);
})}
</div>
</div>
)
))}
</div>
);
};
/**
* 자재별 보기 탭 컴포넌트
*/
const ComparisonMaterialsTab = ({ changes, onMaterialSelect }) => {
if (!changes) return null;
// 모든 자재를 하나의 리스트로 통합
const allMaterials = [];
Object.entries(changes).forEach(([changeType, items]) => {
items.forEach(item => {
allMaterials.push({
...item,
changeType
});
});
});
// 자재명으로 정렬
allMaterials.sort((a, b) => {
const descA = getMaterialDescription(a);
const descB = getMaterialDescription(b);
return descA.localeCompare(descB);
});
return (
<div className="comparison-materials-tab">
<div className="materials-table">
<div className="table-header">
<span>자재명</span>
<span>카테고리</span>
<span>변경 타입</span>
<span>수량 변화</span>
<span>액션</span>
</div>
<div className="table-body">
{allMaterials.map((item, idx) => (
<div key={idx} className="table-row">
<span className="material-desc">
{getMaterialDescription(item)}
</span>
<span className="material-category">
{getMaterialCategory(item)}
</span>
<span className="change-type">
<RevisionStatusIndicator
status={getStatusFromChangeType(item.changeType)}
size="small"
/>
</span>
<span className="quantity-change">
{getQuantityChangeText(item)}
</span>
<span className="actions">
{onMaterialSelect && (
<button
className="btn-select-small"
onClick={() => onMaterialSelect(item)}
>
선택
</button>
)}
</span>
</div>
))}
</div>
</div>
</div>
);
};
/**
* 자재 변경 상세 정보 컴포넌트
*/
const MaterialChangeDetails = ({ item, changeType }) => {
const material = item.material || item.current || item.previous;
const changes = item.changes || {};
return (
<div className="material-change-details">
<div className="detail-grid">
<div className="detail-section">
<h5>기본 정보</h5>
<div className="detail-items">
<div className="detail-item">
<span className="label">자재명:</span>
<span className="value">{material?.original_description}</span>
</div>
<div className="detail-item">
<span className="label">카테고리:</span>
<span className="value">{material?.classified_category}</span>
</div>
<div className="detail-item">
<span className="label">재질/규격:</span>
<span className="value">
{material?.material_grade} {material?.size_spec}
</span>
</div>
</div>
</div>
{Object.keys(changes).length > 0 && (
<div className="detail-section">
<h5>변경 내용</h5>
<div className="detail-items">
{Object.entries(changes).map(([field, change]) => (
<div key={field} className="detail-item">
<span className="label">{getFieldLabel(field)}:</span>
<span className="value change-value">
{typeof change === 'object' ? (
<>
<span className="old-value">{change.previous}</span>
<span className="arrow"></span>
<span className="new-value">{change.current}</span>
{change.change && (
<span className="change-amount">
({change.change > 0 ? '+' : ''}{change.change})
</span>
)}
</>
) : (
change
)}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
};
// 유틸리티 함수들
const getChangeTypeLabel = (changeType) => {
const labels = {
'added': '추가된 자재',
'modified': '수정된 자재',
'removed': '제거된 자재',
'unchanged': '변경없는 자재'
};
return labels[changeType] || changeType;
};
const getStatusFromChangeType = (changeType) => {
const statusMap = {
'added': 'NEW',
'modified': 'UPDATED',
'removed': 'DELETED',
'unchanged': 'COMPLETED'
};
return statusMap[changeType] || 'PENDING';
};
const getMaterialDescription = (item) => {
const material = item.material || item.current || item.previous;
return material?.original_description || '알 수 없는 자재';
};
const getMaterialCategory = (item) => {
const material = item.material || item.current || item.previous;
return material?.classified_category || 'UNKNOWN';
};
const getQuantityChangeText = (item) => {
const changes = item.changes || {};
const quantityChange = changes.quantity;
if (!quantityChange) return '-';
if (typeof quantityChange === 'object') {
return `${quantityChange.previous}${quantityChange.current}`;
}
return quantityChange;
};
const getFieldLabel = (field) => {
const labels = {
'quantity': '수량',
'material_grade': '재질',
'schedule': 'Schedule',
'size_spec': '규격',
'main_nom': '주 호칭',
'red_nom': '축소 호칭',
'unit': '단위',
'length': '길이'
};
return labels[field] || field;
};
export default RevisionComparisonView;

View File

@@ -0,0 +1,323 @@
/* 리비전 상태 표시 컴포넌트 스타일 */
.revision-status-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 12px;
border: 1px solid;
font-weight: 500;
white-space: nowrap;
transition: all 0.2s ease;
}
.revision-status-indicator:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* 크기별 스타일 */
.revision-status-small {
font-size: 11px;
padding: 2px 6px;
gap: 2px;
}
.revision-status-small .status-icon {
font-size: 10px;
}
.revision-status-medium {
font-size: 12px;
padding: 4px 8px;
gap: 4px;
}
.revision-status-medium .status-icon {
font-size: 12px;
}
.revision-status-large {
font-size: 14px;
padding: 6px 12px;
gap: 6px;
}
.revision-status-large .status-icon {
font-size: 14px;
}
/* 우선순위 배지 */
.revision-priority-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 8px;
font-size: 11px;
font-weight: 600;
}
.priority-icon {
font-size: 10px;
}
/* 진행률 바 */
.revision-progress-bar {
width: 100%;
}
.progress-label {
font-size: 12px;
font-weight: 500;
color: #374151;
margin-bottom: 4px;
}
.progress-container {
display: flex;
align-items: center;
gap: 8px;
}
.progress-track {
flex: 1;
height: 8px;
background-color: #f3f4f6;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.progress-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #6b7280;
white-space: nowrap;
}
.progress-count {
font-weight: 500;
}
.progress-percentage {
font-weight: 600;
}
/* 카테고리 요약 */
.revision-category-summary {
background: white;
border-radius: 8px;
padding: 16px;
border: 1px solid #e5e7eb;
}
.summary-header {
margin-bottom: 16px;
}
.summary-header h4 {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.category-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.category-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
}
.category-info {
display: flex;
justify-content: space-between;
align-items: center;
min-width: 120px;
}
.category-name {
font-size: 12px;
font-weight: 500;
color: #374151;
}
.category-count {
font-size: 11px;
color: #6b7280;
}
.category-progress {
flex: 1;
height: 4px;
background-color: #f3f4f6;
border-radius: 2px;
overflow: hidden;
}
.category-progress-bar {
height: 100%;
background-color: #3b82f6;
border-radius: 2px;
transition: width 0.3s ease;
}
/* 변경사항 요약 카드 */
.revision-change-summary {
background: white;
border-radius: 8px;
padding: 16px;
border: 1px solid #e5e7eb;
}
.summary-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.summary-title h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.total-count {
font-size: 12px;
color: #6b7280;
background: #f3f4f6;
padding: 2px 8px;
border-radius: 12px;
}
.change-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: 12px;
}
.change-item {
text-align: center;
padding: 12px 8px;
border-radius: 8px;
background: #f9fafb;
border: 1px solid #f3f4f6;
transition: all 0.2s ease;
}
.change-item:hover {
background: #f3f4f6;
transform: translateY(-1px);
}
.change-header {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
margin-bottom: 4px;
}
.change-icon {
font-size: 12px;
}
.change-label {
font-size: 11px;
color: #6b7280;
font-weight: 500;
}
.change-count {
font-size: 18px;
font-weight: 700;
margin-bottom: 2px;
}
.change-percentage {
font-size: 10px;
color: #9ca3af;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.revision-status-indicator {
font-size: 11px;
padding: 3px 6px;
}
.progress-info {
flex-direction: column;
gap: 2px;
}
.category-item {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.category-info {
min-width: auto;
}
.change-grid {
grid-template-columns: repeat(2, 1fr);
}
.summary-title {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
}
/* 애니메이션 */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.revision-status-indicator.loading {
animation: pulse 1.5s ease-in-out infinite;
}
.progress-fill {
background: linear-gradient(90deg,
currentColor 0%,
currentColor 50%,
rgba(255,255,255,0.3) 50%,
rgba(255,255,255,0.3) 100%);
background-size: 20px 100%;
animation: progress-shine 2s linear infinite;
}
@keyframes progress-shine {
0% {
background-position: -20px 0;
}
100% {
background-position: 100% 0;
}
}

View File

@@ -0,0 +1,279 @@
import React from 'react';
import './RevisionStatusIndicator.css';
/**
* 리비전 상태 표시 컴포넌트
*/
const RevisionStatusIndicator = ({
status,
size = 'medium',
showText = true,
className = ''
}) => {
const getStatusConfig = (status) => {
const configs = {
'NEW': {
color: '#10b981',
bgColor: '#d1fae5',
text: '신규',
icon: '✨'
},
'ADDITIONAL': {
color: '#ef4444',
bgColor: '#fee2e2',
text: '추가구매',
icon: ''
},
'EXCESS': {
color: '#f59e0b',
bgColor: '#fef3c7',
text: '잉여재고',
icon: '📦'
},
'UPDATED': {
color: '#3b82f6',
bgColor: '#dbeafe',
text: '수량변경',
icon: '🔄'
},
'PENDING': {
color: '#6366f1',
bgColor: '#e0e7ff',
text: '구매대기',
icon: '⏳'
},
'COMPLETED': {
color: '#6b7280',
bgColor: '#f3f4f6',
text: '완료',
icon: '✅'
},
'DELETED': {
color: '#dc2626',
bgColor: '#fecaca',
text: '삭제',
icon: '❌'
},
'INVENTORY': {
color: '#f97316',
bgColor: '#fed7aa',
text: '재고',
icon: '🏪'
}
};
return configs[status] || {
color: '#6b7280',
bgColor: '#f3f4f6',
text: status || 'Unknown',
icon: '❓'
};
};
const config = getStatusConfig(status);
const sizeClass = `revision-status-${size}`;
return (
<div
className={`revision-status-indicator ${sizeClass} ${className}`}
style={{
backgroundColor: config.bgColor,
color: config.color,
borderColor: config.color
}}
>
<span className="status-icon">{config.icon}</span>
{showText && <span className="status-text">{config.text}</span>}
</div>
);
};
/**
* 리비전 처리 우선순위 표시
*/
export const RevisionPriorityBadge = ({ priority, className = '' }) => {
const getPriorityConfig = (priority) => {
const configs = {
'high': {
color: '#dc2626',
bgColor: '#fecaca',
text: '높음',
icon: '🔥'
},
'medium': {
color: '#f59e0b',
bgColor: '#fef3c7',
text: '보통',
icon: '⚡'
},
'low': {
color: '#10b981',
bgColor: '#d1fae5',
text: '낮음',
icon: '🌱'
}
};
return configs[priority] || configs['medium'];
};
const config = getPriorityConfig(priority);
return (
<div
className={`revision-priority-badge ${className}`}
style={{
backgroundColor: config.bgColor,
color: config.color
}}
>
<span className="priority-icon">{config.icon}</span>
<span className="priority-text">{config.text}</span>
</div>
);
};
/**
* 리비전 진행률 표시
*/
export const RevisionProgressBar = ({
current,
total,
label = '',
showPercentage = true,
className = ''
}) => {
const percentage = total > 0 ? Math.round((current / total) * 100) : 0;
const getProgressColor = (percentage) => {
if (percentage >= 100) return '#10b981';
if (percentage >= 80) return '#3b82f6';
if (percentage >= 50) return '#f59e0b';
return '#ef4444';
};
const progressColor = getProgressColor(percentage);
return (
<div className={`revision-progress-bar ${className}`}>
{label && <div className="progress-label">{label}</div>}
<div className="progress-container">
<div className="progress-track">
<div
className="progress-fill"
style={{
width: `${percentage}%`,
backgroundColor: progressColor
}}
/>
</div>
<div className="progress-info">
<span className="progress-count">{current}/{total}</span>
{showPercentage && (
<span className="progress-percentage">{percentage}%</span>
)}
</div>
</div>
</div>
);
};
/**
* 리비전 카테고리 상태 요약
*/
export const RevisionCategorySummary = ({ categoryStats, className = '' }) => {
if (!categoryStats) return null;
const totalMaterials = Object.values(categoryStats).reduce(
(sum, stats) => sum + stats.total, 0
);
const processedMaterials = Object.values(categoryStats).reduce(
(sum, stats) => sum + stats.processed, 0
);
return (
<div className={`revision-category-summary ${className}`}>
<div className="summary-header">
<h4>카테고리별 처리 현황</h4>
<RevisionProgressBar
current={processedMaterials}
total={totalMaterials}
label="전체 진행률"
/>
</div>
<div className="category-list">
{Object.entries(categoryStats).map(([category, stats]) => (
<div key={category} className="category-item">
<div className="category-info">
<span className="category-name">{category}</span>
<span className="category-count">
{stats.processed}/{stats.total}
</span>
</div>
<div className="category-progress">
<div
className="category-progress-bar"
style={{
width: `${stats.total > 0 ? (stats.processed / stats.total) * 100 : 0}%`
}}
/>
</div>
</div>
))}
</div>
</div>
);
};
/**
* 리비전 변경사항 요약 카드
*/
export const RevisionChangeSummary = ({ changes, className = '' }) => {
if (!changes) return null;
const changeTypes = [
{ key: 'added', label: '추가', icon: '', color: '#10b981' },
{ key: 'modified', label: '수정', icon: '🔄', color: '#3b82f6' },
{ key: 'removed', label: '제거', icon: '', color: '#ef4444' },
{ key: 'unchanged', label: '변경없음', icon: '✅', color: '#6b7280' }
];
const totalChanges = changeTypes.reduce(
(sum, type) => sum + (changes[type.key]?.length || 0), 0
);
return (
<div className={`revision-change-summary ${className}`}>
<div className="summary-title">
<h4>변경사항 요약</h4>
<span className="total-count"> {totalChanges}</span>
</div>
<div className="change-grid">
{changeTypes.map(type => {
const count = changes[type.key]?.length || 0;
const percentage = totalChanges > 0 ? (count / totalChanges) * 100 : 0;
return (
<div key={type.key} className="change-item">
<div className="change-header">
<span className="change-icon">{type.icon}</span>
<span className="change-label">{type.label}</span>
</div>
<div className="change-count" style={{ color: type.color }}>
{count}
</div>
<div className="change-percentage">
{percentage.toFixed(1)}%
</div>
</div>
);
})}
</div>
</div>
);
};
export default RevisionStatusIndicator;

View File

@@ -0,0 +1,333 @@
/**
* PIPE 이슈 관리 훅
*
* 스냅샷 기반 도면별/단관별 이슈 관리 기능 제공
*/
import { useState, useCallback, useEffect } from 'react';
import api from '../api';
export const usePipeIssue = (jobNo = null, snapshotId = null) => {
// 상태 관리
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// 데이터 상태
const [snapshots, setSnapshots] = useState([]);
const [currentSnapshot, setCurrentSnapshot] = useState(null);
const [drawingIssues, setDrawingIssues] = useState([]);
const [segmentIssues, setSegmentIssues] = useState([]);
const [issueReport, setIssueReport] = useState(null);
// 필터 상태
const [selectedArea, setSelectedArea] = useState('');
const [selectedDrawing, setSelectedDrawing] = useState('');
const [statusFilter, setStatusFilter] = useState('');
// 스냅샷 목록 조회
const fetchSnapshots = useCallback(async () => {
if (!jobNo) return;
setLoading(true);
setError('');
try {
const response = await api.get(`/pipe-issue/snapshots/${jobNo}`);
setSnapshots(response.data.snapshots || []);
// 첫 번째 활성 스냅샷을 기본 선택
const activeSnapshot = response.data.snapshots?.find(s => s.is_locked);
if (activeSnapshot && !currentSnapshot) {
setCurrentSnapshot(activeSnapshot);
}
return response.data;
} catch (err) {
const errorMessage = err.response?.data?.detail || '스냅샷 조회 실패';
setError(errorMessage);
console.error('스냅샷 조회 실패:', err);
return null;
} finally {
setLoading(false);
}
}, [jobNo, currentSnapshot]);
// 도면 이슈 목록 조회
const fetchDrawingIssues = useCallback(async (filters = {}) => {
const targetSnapshotId = filters.snapshotId || snapshotId || currentSnapshot?.snapshot_id;
if (!targetSnapshotId) return;
setLoading(true);
setError('');
try {
const params = new URLSearchParams();
if (filters.area || selectedArea) params.append('area', filters.area || selectedArea);
if (filters.drawing_name || selectedDrawing) params.append('drawing_name', filters.drawing_name || selectedDrawing);
if (filters.status_filter || statusFilter) params.append('status_filter', filters.status_filter || statusFilter);
const response = await api.get(`/pipe-issue/drawing-issues/${targetSnapshotId}?${params}`);
setDrawingIssues(response.data.issues || []);
return response.data;
} catch (err) {
const errorMessage = err.response?.data?.detail || '도면 이슈 조회 실패';
setError(errorMessage);
console.error('도면 이슈 조회 실패:', err);
return null;
} finally {
setLoading(false);
}
}, [snapshotId, currentSnapshot, selectedArea, selectedDrawing, statusFilter]);
// 단관 이슈 목록 조회
const fetchSegmentIssues = useCallback(async (filters = {}) => {
const targetSnapshotId = filters.snapshotId || snapshotId || currentSnapshot?.snapshot_id;
if (!targetSnapshotId) return;
setLoading(true);
setError('');
try {
const params = new URLSearchParams();
if (filters.segment_id) params.append('segment_id', filters.segment_id);
if (filters.status_filter || statusFilter) params.append('status_filter', filters.status_filter || statusFilter);
const response = await api.get(`/pipe-issue/segment-issues/${targetSnapshotId}?${params}`);
setSegmentIssues(response.data.issues || []);
return response.data;
} catch (err) {
const errorMessage = err.response?.data?.detail || '단관 이슈 조회 실패';
setError(errorMessage);
console.error('단관 이슈 조회 실패:', err);
return null;
} finally {
setLoading(false);
}
}, [snapshotId, currentSnapshot, statusFilter]);
// 도면 이슈 생성
const createDrawingIssue = useCallback(async (issueData) => {
const targetSnapshotId = issueData.snapshot_id || snapshotId || currentSnapshot?.snapshot_id;
if (!targetSnapshotId) {
setError('스냅샷 ID가 필요합니다.');
return null;
}
setLoading(true);
setError('');
try {
const response = await api.post('/pipe-issue/drawing-issues', {
...issueData,
snapshot_id: targetSnapshotId
});
// 목록 새로고침
await fetchDrawingIssues();
return response.data;
} catch (err) {
const errorMessage = err.response?.data?.detail || '도면 이슈 생성 실패';
setError(errorMessage);
console.error('도면 이슈 생성 실패:', err);
return null;
} finally {
setLoading(false);
}
}, [snapshotId, currentSnapshot, fetchDrawingIssues]);
// 단관 이슈 생성
const createSegmentIssue = useCallback(async (issueData) => {
const targetSnapshotId = issueData.snapshot_id || snapshotId || currentSnapshot?.snapshot_id;
if (!targetSnapshotId) {
setError('스냅샷 ID가 필요합니다.');
return null;
}
setLoading(true);
setError('');
try {
const response = await api.post('/pipe-issue/segment-issues', {
...issueData,
snapshot_id: targetSnapshotId
});
// 목록 새로고침
await fetchSegmentIssues();
return response.data;
} catch (err) {
const errorMessage = err.response?.data?.detail || '단관 이슈 생성 실패';
setError(errorMessage);
console.error('단관 이슈 생성 실패:', err);
return null;
} finally {
setLoading(false);
}
}, [snapshotId, currentSnapshot, fetchSegmentIssues]);
// 도면 이슈 상태 업데이트
const updateDrawingIssueStatus = useCallback(async (issueId, statusData) => {
setLoading(true);
setError('');
try {
const response = await api.put(`/pipe-issue/drawing-issues/${issueId}/status`, statusData);
// 목록 새로고침
await fetchDrawingIssues();
return response.data;
} catch (err) {
const errorMessage = err.response?.data?.detail || '도면 이슈 상태 업데이트 실패';
setError(errorMessage);
console.error('도면 이슈 상태 업데이트 실패:', err);
return null;
} finally {
setLoading(false);
}
}, [fetchDrawingIssues]);
// 단관 이슈 상태 업데이트
const updateSegmentIssueStatus = useCallback(async (issueId, statusData) => {
setLoading(true);
setError('');
try {
const response = await api.put(`/pipe-issue/segment-issues/${issueId}/status`, statusData);
// 목록 새로고침
await fetchSegmentIssues();
return response.data;
} catch (err) {
const errorMessage = err.response?.data?.detail || '단관 이슈 상태 업데이트 실패';
setError(errorMessage);
console.error('단관 이슈 상태 업데이트 실패:', err);
return null;
} finally {
setLoading(false);
}
}, [fetchSegmentIssues]);
// 이슈 리포트 생성
const generateIssueReport = useCallback(async () => {
const targetSnapshotId = snapshotId || currentSnapshot?.snapshot_id;
if (!targetSnapshotId) return;
setLoading(true);
setError('');
try {
const response = await api.get(`/pipe-issue/report/${targetSnapshotId}`);
setIssueReport(response.data);
return response.data;
} catch (err) {
const errorMessage = err.response?.data?.detail || '이슈 리포트 생성 실패';
setError(errorMessage);
console.error('이슈 리포트 생성 실패:', err);
return null;
} finally {
setLoading(false);
}
}, [snapshotId, currentSnapshot]);
// 초기 데이터 로드
useEffect(() => {
if (jobNo) {
fetchSnapshots();
}
}, [jobNo, fetchSnapshots]);
// 스냅샷 변경 시 이슈 목록 새로고침
useEffect(() => {
if (currentSnapshot?.snapshot_id) {
fetchDrawingIssues();
fetchSegmentIssues();
}
}, [currentSnapshot, fetchDrawingIssues, fetchSegmentIssues]);
// 필터 변경 시 이슈 목록 새로고침
useEffect(() => {
if (currentSnapshot?.snapshot_id) {
fetchDrawingIssues();
}
}, [selectedArea, selectedDrawing, statusFilter, fetchDrawingIssues]);
// 편의 함수들
const getIssueStats = useCallback(() => {
const totalDrawingIssues = drawingIssues.length;
const totalSegmentIssues = segmentIssues.length;
const drawingStats = {
total: totalDrawingIssues,
open: drawingIssues.filter(i => i.status === 'open').length,
in_progress: drawingIssues.filter(i => i.status === 'in_progress').length,
resolved: drawingIssues.filter(i => i.status === 'resolved').length,
critical: drawingIssues.filter(i => i.severity === 'critical').length,
high: drawingIssues.filter(i => i.severity === 'high').length
};
const segmentStats = {
total: totalSegmentIssues,
open: segmentIssues.filter(i => i.status === 'open').length,
in_progress: segmentIssues.filter(i => i.status === 'in_progress').length,
resolved: segmentIssues.filter(i => i.status === 'resolved').length,
critical: segmentIssues.filter(i => i.severity === 'critical').length,
high: segmentIssues.filter(i => i.severity === 'high').length
};
return {
drawing: drawingStats,
segment: segmentStats,
total: totalDrawingIssues + totalSegmentIssues
};
}, [drawingIssues, segmentIssues]);
const hasActiveSnapshot = currentSnapshot && currentSnapshot.is_locked;
const canManageIssues = hasActiveSnapshot;
return {
// 상태
loading,
error,
snapshots,
currentSnapshot,
drawingIssues,
segmentIssues,
issueReport,
// 필터
selectedArea,
selectedDrawing,
statusFilter,
setSelectedArea,
setSelectedDrawing,
setStatusFilter,
// 액션
fetchSnapshots,
fetchDrawingIssues,
fetchSegmentIssues,
createDrawingIssue,
createSegmentIssue,
updateDrawingIssueStatus,
updateSegmentIssueStatus,
generateIssueReport,
setCurrentSnapshot,
// 편의 함수
getIssueStats,
clearError: () => setError(''),
// 상태 확인
hasActiveSnapshot,
canManageIssues,
// 통계
stats: getIssueStats()
};
};
export default usePipeIssue;

View File

@@ -0,0 +1,386 @@
/**
* PIPE 전용 리비전 관리 훅
*
* Cutting Plan 작성 전/후에 따른 차별화된 리비전 처리
*/
import { useState, useEffect, useCallback } from 'react';
import api from '../api';
import { PipeLogger, PIPE_CONSTANTS } from '../utils/pipeUtils';
export const usePipeRevision = (jobNo, fileId) => {
const [revisionStatus, setRevisionStatus] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [comparisonResult, setComparisonResult] = useState(null);
// 리비전 상태 확인
const checkRevisionStatus = useCallback(async () => {
if (!jobNo || !fileId) return;
setLoading(true);
setError('');
try {
PipeLogger.logPipeOperation('리비전 상태 확인', jobNo, { fileId });
const response = await api.post('/pipe-revision/check-status', {
job_no: jobNo,
new_file_id: parseInt(fileId)
});
setRevisionStatus(response.data);
PipeLogger.logPipeOperation('리비전 상태 확인 완료', jobNo, {
revisionType: response.data.revision_type,
requiresAction: response.data.requires_action
});
return response.data;
} catch (err) {
const errorMessage = err.response?.data?.detail || 'PIPE 리비전 상태 확인 실패';
setError(errorMessage);
PipeLogger.logPipeError('리비전 상태 확인', jobNo, err, { fileId });
return null;
} finally {
setLoading(false);
}
}, [jobNo, fileId]);
// Cutting Plan 작성 전 리비전 처리
const handlePreCuttingPlanRevision = useCallback(async () => {
if (!jobNo || !fileId) return null;
setLoading(true);
setError('');
try {
const response = await api.post('/pipe-revision/handle-pre-cutting-plan', {
job_no: jobNo,
new_file_id: parseInt(fileId)
});
return response.data;
} catch (err) {
const errorMessage = err.response?.data?.detail || 'Cutting Plan 작성 전 리비전 처리 실패';
setError(errorMessage);
console.error('Pre-cutting-plan 리비전 처리 실패:', err);
return null;
} finally {
setLoading(false);
}
}, [jobNo, fileId]);
// Cutting Plan 작성 후 리비전 처리
const handlePostCuttingPlanRevision = useCallback(async () => {
if (!jobNo || !fileId) return null;
setLoading(true);
setError('');
try {
const response = await api.post('/pipe-revision/handle-post-cutting-plan', {
job_no: jobNo,
new_file_id: parseInt(fileId)
});
setComparisonResult(response.data);
return response.data;
} catch (err) {
const errorMessage = err.response?.data?.detail || 'Cutting Plan 작성 후 리비전 처리 실패';
setError(errorMessage);
console.error('Post-cutting-plan 리비전 처리 실패:', err);
return null;
} finally {
setLoading(false);
}
}, [jobNo, fileId]);
// 리비전 비교 결과 상세 조회
const getComparisonDetails = useCallback(async (comparisonId) => {
setLoading(true);
setError('');
try {
const response = await api.get(`/pipe-revision/comparison/${comparisonId}`);
return response.data;
} catch (err) {
const errorMessage = err.response?.data?.detail || '리비전 비교 결과 조회 실패';
setError(errorMessage);
console.error('리비전 비교 결과 조회 실패:', err);
return null;
} finally {
setLoading(false);
}
}, []);
// 리비전 변경사항 적용
const applyRevisionChanges = useCallback(async (comparisonId) => {
setLoading(true);
setError('');
try {
const response = await api.post(`/pipe-revision/comparison/${comparisonId}/apply`);
return response.data;
} catch (err) {
const errorMessage = err.response?.data?.detail || '리비전 변경사항 적용 실패';
setError(errorMessage);
console.error('리비전 변경사항 적용 실패:', err);
return null;
} finally {
setLoading(false);
}
}, []);
// 구매 영향 분석
const getPurchaseImpact = useCallback(async (comparisonId) => {
setLoading(true);
setError('');
try {
const response = await api.get(`/pipe-revision/comparison/${comparisonId}/purchase-impact`);
return response.data;
} catch (err) {
const errorMessage = err.response?.data?.detail || '구매 영향 분석 실패';
setError(errorMessage);
console.error('구매 영향 분석 실패:', err);
return null;
} finally {
setLoading(false);
}
}, []);
// 리비전 이력 조회
const getRevisionHistory = useCallback(async () => {
if (!jobNo) return null;
setLoading(true);
setError('');
try {
const response = await api.get(`/pipe-revision/job/${jobNo}/history`);
return response.data;
} catch (err) {
const errorMessage = err.response?.data?.detail || '리비전 이력 조회 실패';
setError(errorMessage);
console.error('리비전 이력 조회 실패:', err);
return null;
} finally {
setLoading(false);
}
}, [jobNo]);
// 자동 리비전 처리 (상태에 따라 적절한 처리 수행)
const processRevisionAutomatically = useCallback(async () => {
try {
// 1. 리비전 상태 확인
const status = await checkRevisionStatus();
if (!status || !status.requires_action) {
return {
success: true,
type: 'no_action_needed',
message: status?.message || '리비전 처리가 필요하지 않습니다.'
};
}
// 2. 리비전 타입에 따른 처리
if (status.revision_type === 'pre_cutting_plan') {
const result = await handlePreCuttingPlanRevision();
return {
success: result !== null,
type: 'pre_cutting_plan',
data: result,
message: result?.message || 'Cutting Plan 작성 전 리비전 처리 완료'
};
} else if (status.revision_type === 'post_cutting_plan') {
const result = await handlePostCuttingPlanRevision();
return {
success: result !== null,
type: 'post_cutting_plan',
data: result,
message: result?.message || 'Cutting Plan 작성 후 리비전 처리 완료'
};
}
return {
success: false,
type: 'unknown',
message: '알 수 없는 리비전 타입입니다.'
};
} catch (err) {
console.error('자동 리비전 처리 실패:', err);
return {
success: false,
type: 'error',
message: '자동 리비전 처리 중 오류가 발생했습니다.'
};
}
}, [checkRevisionStatus, handlePreCuttingPlanRevision, handlePostCuttingPlanRevision]);
// 컴포넌트 마운트 시 리비전 상태 확인
useEffect(() => {
if (jobNo && fileId) {
checkRevisionStatus();
}
}, [jobNo, fileId, checkRevisionStatus]);
return {
// 상태
revisionStatus,
comparisonResult,
loading,
error,
// 액션
checkRevisionStatus,
handlePreCuttingPlanRevision,
handlePostCuttingPlanRevision,
getComparisonDetails,
applyRevisionChanges,
getPurchaseImpact,
getRevisionHistory,
processRevisionAutomatically,
// Cutting Plan 확정 (스냅샷 생성)
finalizeCuttingPlan: useCallback(async () => {
if (!jobNo) return null;
setLoading(true);
setError('');
try {
const response = await api.post('/pipe-snapshot/finalize-cutting-plan', {
job_no: jobNo,
created_by: 'user' // 추후 실제 사용자 정보로 변경
});
return response.data;
} catch (err) {
const errorMessage = err.response?.data?.detail || 'Cutting Plan 확정 실패';
setError(errorMessage);
console.error('Cutting Plan 확정 실패:', err);
return null;
} finally {
setLoading(false);
}
}, [jobNo]),
// 스냅샷 상태 확인
getSnapshotStatus: useCallback(async () => {
if (!jobNo) return null;
try {
const response = await api.get(`/pipe-snapshot/status/${jobNo}`);
return response.data;
} catch (err) {
console.error('스냅샷 상태 확인 실패:', err);
return null;
}
}, [jobNo]),
// 스냅샷 단관 정보 조회
getSnapshotSegments: useCallback(async (snapshotId, area = null, drawingName = null) => {
try {
const params = new URLSearchParams();
if (area) params.append('area', area);
if (drawingName) params.append('drawing_name', drawingName);
const response = await api.get(`/pipe-snapshot/segments/${snapshotId}?${params}`);
return response.data;
} catch (err) {
console.error('스냅샷 단관 조회 실패:', err);
return null;
}
}, []),
// 사용 가능한 구역 목록 조회
getAvailableAreas: useCallback(async (snapshotId) => {
try {
const response = await api.get(`/pipe-snapshot/areas/${snapshotId}`);
return response.data;
} catch (err) {
console.error('구역 목록 조회 실패:', err);
return null;
}
}, []),
// 사용 가능한 도면 목록 조회
getAvailableDrawings: useCallback(async (snapshotId, area = null) => {
try {
const params = area ? `?area=${encodeURIComponent(area)}` : '';
const response = await api.get(`/pipe-snapshot/drawings/${snapshotId}${params}`);
return response.data;
} catch (err) {
console.error('도면 목록 조회 실패:', err);
return null;
}
}, []),
// 리비전 보호 상태 확인
checkRevisionProtection: useCallback(async () => {
if (!jobNo) return null;
try {
const response = await api.get(`/pipe-snapshot/revision-protection/${jobNo}`);
return response.data;
} catch (err) {
console.error('리비전 보호 상태 확인 실패:', err);
return null;
}
}, [jobNo]),
// 확정된 Excel 내보내기
exportFinalizedExcel: useCallback(async () => {
if (!jobNo) return null;
try {
const response = await api.get(`/pipe-excel/export-finalized/${jobNo}`, {
responseType: 'blob'
});
// 파일 다운로드
const blob = new Blob([response.data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `PIPE_Cutting_Plan_${jobNo}_FINALIZED.xlsx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
return { success: true, message: 'Excel 파일이 다운로드되었습니다.' };
} catch (err) {
const errorMessage = err.response?.data?.detail || '확정된 Excel 내보내기 실패';
setError(errorMessage);
console.error('확정된 Excel 내보내기 실패:', err);
return { success: false, message: errorMessage };
}
}, [jobNo]),
// 확정 상태 확인
checkFinalizationStatus: useCallback(async () => {
if (!jobNo) return null;
try {
const response = await api.get(`/pipe-excel/check-finalization/${jobNo}`);
return response.data;
} catch (err) {
console.error('확정 상태 확인 실패:', err);
return null;
}
}, [jobNo]),
// 유틸리티
clearError: () => setError(''),
isPreCuttingPlan: revisionStatus?.revision_type === 'pre_cutting_plan',
isPostCuttingPlan: revisionStatus?.revision_type === 'post_cutting_plan',
requiresAction: revisionStatus?.requires_action || false
};
};
export default usePipeRevision;

View File

@@ -0,0 +1,389 @@
import { useState, useCallback } from 'react';
import { api } from '../api';
/**
* 리비전 비교 훅
* 두 리비전 간의 자재 비교 및 차이점 분석
*/
export const useRevisionComparison = () => {
const [comparisonResult, setComparisonResult] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const compareRevisions = useCallback(async (currentFileId, previousFileId, categoryFilter = null) => {
try {
setLoading(true);
setError(null);
const params = new URLSearchParams({
current_file_id: currentFileId,
previous_file_id: previousFileId
});
if (categoryFilter) {
params.append('category_filter', categoryFilter);
}
const response = await api.post(`/revision-comparison/compare?${params}`);
if (response.data.success) {
setComparisonResult(response.data.data);
return response.data.data;
} else {
throw new Error(response.data.message || '리비전 비교 실패');
}
} catch (err) {
console.error('리비전 비교 실패:', err);
setError(err.message);
throw err;
} finally {
setLoading(false);
}
}, []);
const getCategoryComparison = useCallback(async (currentFileId, previousFileId, category) => {
if (category === 'PIPE') {
console.warn('PIPE 카테고리는 별도 처리가 필요합니다.');
return null;
}
return await compareRevisions(currentFileId, previousFileId, category);
}, [compareRevisions]);
const getComparisonSummary = useCallback((comparison) => {
if (!comparison || !comparison.summary) return null;
const { summary } = comparison;
return {
totalChanges: summary.modified + summary.added + summary.removed,
unchanged: summary.unchanged,
modified: summary.modified,
added: summary.added,
removed: summary.removed,
changePercentage: summary.previous_count > 0
? ((summary.modified + summary.added + summary.removed) / summary.previous_count * 100)
: 0,
hasSignificantChanges: (summary.modified + summary.added + summary.removed) > 0
};
}, []);
const getChangesByType = useCallback((comparison, changeType) => {
if (!comparison || !comparison.changes) return [];
return comparison.changes[changeType] || [];
}, []);
const getMaterialChanges = useCallback((comparison, materialId) => {
if (!comparison || !comparison.changes) return null;
// 모든 변경 타입에서 해당 자재 찾기
for (const [changeType, changes] of Object.entries(comparison.changes)) {
const materialChange = changes.find(change => {
const material = change.material || change.current || change.previous;
return material && material.id === materialId;
});
if (materialChange) {
return {
changeType,
...materialChange
};
}
}
return null;
}, []);
const filterChangesByCategory = useCallback((comparison, category) => {
if (!comparison || !comparison.changes) return null;
const filteredChanges = {};
for (const [changeType, changes] of Object.entries(comparison.changes)) {
filteredChanges[changeType] = changes.filter(change => {
const material = change.material || change.current || change.previous;
return material && material.classified_category === category;
});
}
// 필터링된 요약 통계 계산
const filteredSummary = {
unchanged: filteredChanges.unchanged?.length || 0,
modified: filteredChanges.modified?.length || 0,
added: filteredChanges.added?.length || 0,
removed: filteredChanges.removed?.length || 0
};
return {
...comparison,
changes: filteredChanges,
summary: {
...comparison.summary,
...filteredSummary,
category_filter: category
}
};
}, []);
const exportComparisonReport = useCallback(async (comparison, format = 'excel') => {
try {
setLoading(true);
const response = await api.post('/revision-comparison/export', {
comparison_data: comparison,
format
}, {
responseType: 'blob'
});
// 파일 다운로드
const blob = new Blob([response.data], {
type: format === 'excel'
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
: 'application/pdf'
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `revision_comparison_${new Date().toISOString().split('T')[0]}.${format === 'excel' ? 'xlsx' : 'pdf'}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
return true;
} catch (err) {
console.error('비교 보고서 내보내기 실패:', err);
setError(err.message);
return false;
} finally {
setLoading(false);
}
}, []);
const clearComparison = useCallback(() => {
setComparisonResult(null);
setError(null);
}, []);
return {
comparisonResult,
loading,
error,
compareRevisions,
getCategoryComparison,
getComparisonSummary,
getChangesByType,
getMaterialChanges,
filterChangesByCategory,
exportComparisonReport,
clearComparison,
setError
};
};
/**
* 리비전 차이점 시각화 훅
*/
export const useRevisionVisualization = (comparison) => {
const getChangeVisualizationData = useCallback(() => {
if (!comparison || !comparison.summary) return null;
const { summary } = comparison;
return {
pieChart: [
{ name: '변경없음', value: summary.unchanged, color: '#10b981' },
{ name: '수정됨', value: summary.modified, color: '#f59e0b' },
{ name: '추가됨', value: summary.added, color: '#3b82f6' },
{ name: '제거됨', value: summary.removed, color: '#ef4444' }
].filter(item => item.value > 0),
barChart: {
categories: ['변경없음', '수정됨', '추가됨', '제거됨'],
data: [summary.unchanged, summary.modified, summary.added, summary.removed],
colors: ['#10b981', '#f59e0b', '#3b82f6', '#ef4444']
},
summary: {
totalItems: summary.previous_count + summary.added,
changeRate: summary.previous_count > 0
? ((summary.modified + summary.added + summary.removed) / summary.previous_count * 100)
: 0,
stabilityRate: summary.previous_count > 0
? (summary.unchanged / summary.previous_count * 100)
: 0
}
};
}, [comparison]);
const getCategoryBreakdown = useCallback(() => {
if (!comparison || !comparison.changes) return null;
const categoryStats = {};
// 모든 변경사항을 카테고리별로 분류
Object.entries(comparison.changes).forEach(([changeType, changes]) => {
changes.forEach(change => {
const material = change.material || change.current || change.previous;
const category = material?.classified_category || 'UNKNOWN';
if (!categoryStats[category]) {
categoryStats[category] = {
unchanged: 0,
modified: 0,
added: 0,
removed: 0,
total: 0
};
}
categoryStats[category][changeType]++;
categoryStats[category].total++;
});
});
return Object.entries(categoryStats).map(([category, stats]) => ({
category,
...stats,
changeRate: stats.total > 0 ? ((stats.modified + stats.added + stats.removed) / stats.total * 100) : 0
}));
}, [comparison]);
const getTimelineData = useCallback(() => {
if (!comparison) return null;
return {
comparisonDate: comparison.comparison_date,
previousVersion: {
fileId: comparison.previous_file_id,
materialCount: comparison.summary?.previous_count || 0
},
currentVersion: {
fileId: comparison.current_file_id,
materialCount: comparison.summary?.current_count || 0
},
changes: {
added: comparison.summary?.added || 0,
removed: comparison.summary?.removed || 0,
modified: comparison.summary?.modified || 0
}
};
}, [comparison]);
return {
getChangeVisualizationData,
getCategoryBreakdown,
getTimelineData
};
};
/**
* 리비전 비교 필터링 훅
*/
export const useRevisionFiltering = (comparison) => {
const [filters, setFilters] = useState({
category: 'all',
changeType: 'all',
searchTerm: '',
showOnlySignificant: false
});
const updateFilter = useCallback((filterName, value) => {
setFilters(prev => ({
...prev,
[filterName]: value
}));
}, []);
const getFilteredChanges = useCallback(() => {
if (!comparison || !comparison.changes) return null;
let filteredChanges = { ...comparison.changes };
// 카테고리 필터
if (filters.category !== 'all') {
Object.keys(filteredChanges).forEach(changeType => {
filteredChanges[changeType] = filteredChanges[changeType].filter(change => {
const material = change.material || change.current || change.previous;
return material?.classified_category === filters.category;
});
});
}
// 변경 타입 필터
if (filters.changeType !== 'all') {
const selectedChanges = filteredChanges[filters.changeType] || [];
filteredChanges = {
unchanged: filters.changeType === 'unchanged' ? selectedChanges : [],
modified: filters.changeType === 'modified' ? selectedChanges : [],
added: filters.changeType === 'added' ? selectedChanges : [],
removed: filters.changeType === 'removed' ? selectedChanges : []
};
}
// 검색어 필터
if (filters.searchTerm) {
const searchLower = filters.searchTerm.toLowerCase();
Object.keys(filteredChanges).forEach(changeType => {
filteredChanges[changeType] = filteredChanges[changeType].filter(change => {
const material = change.material || change.current || change.previous;
const description = material?.original_description || '';
return description.toLowerCase().includes(searchLower);
});
});
}
// 중요한 변경사항만 표시
if (filters.showOnlySignificant) {
Object.keys(filteredChanges).forEach(changeType => {
if (changeType === 'unchanged') {
filteredChanges[changeType] = []; // 변경없는 항목 제외
} else {
filteredChanges[changeType] = filteredChanges[changeType].filter(change => {
// 수량 변화가 큰 경우만 표시
const changes = change.changes || {};
const quantityChange = changes.quantity?.change || 0;
return Math.abs(quantityChange) > 1; // 1개 이상 변경된 경우만
});
}
});
}
return filteredChanges;
}, [comparison, filters]);
const getFilterSummary = useCallback(() => {
const filteredChanges = getFilteredChanges();
if (!filteredChanges) return null;
const summary = {
unchanged: filteredChanges.unchanged?.length || 0,
modified: filteredChanges.modified?.length || 0,
added: filteredChanges.added?.length || 0,
removed: filteredChanges.removed?.length || 0
};
summary.total = summary.unchanged + summary.modified + summary.added + summary.removed;
return summary;
}, [getFilteredChanges]);
const resetFilters = useCallback(() => {
setFilters({
category: 'all',
changeType: 'all',
searchTerm: '',
showOnlySignificant: false
});
}, []);
return {
filters,
updateFilter,
getFilteredChanges,
getFilterSummary,
resetFilters
};
};

View File

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

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

@@ -0,0 +1,399 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '../api';
/**
* 리비전 상태 관리 훅
* 리비전 진행 상태, 히스토리, 확정 등 관리
*/
export const useRevisionStatus = (jobNo, fileId) => {
const [revisionStatus, setRevisionStatus] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const loadRevisionStatus = useCallback(async () => {
if (!jobNo || !fileId) return;
try {
setLoading(true);
setError(null);
const response = await api.get(`/revision-status/${jobNo}/${fileId}`);
if (response.data.success) {
setRevisionStatus(response.data.data);
}
} catch (err) {
console.error('리비전 상태 조회 실패:', err);
setError(err.message);
} finally {
setLoading(false);
}
}, [jobNo, fileId]);
const createComparisonRecord = useCallback(async (previousFileId, comparisonResult) => {
try {
const response = await api.post('/revision-status/create-comparison', {
job_no: jobNo,
current_file_id: fileId,
previous_file_id: previousFileId,
comparison_result: comparisonResult
});
if (response.data.success) {
// 상태 새로고침
await loadRevisionStatus();
return response.data.data.comparison_id;
} else {
throw new Error(response.data.message || '비교 기록 생성 실패');
}
} catch (err) {
console.error('비교 기록 생성 실패:', err);
setError(err.message);
throw err;
}
}, [jobNo, fileId, loadRevisionStatus]);
const applyComparison = useCallback(async (comparisonId) => {
try {
setLoading(true);
const response = await api.post(`/revision-status/apply-comparison/${comparisonId}`);
if (response.data.success) {
// 상태 새로고침
await loadRevisionStatus();
return response.data.data;
} else {
throw new Error(response.data.message || '비교 결과 적용 실패');
}
} catch (err) {
console.error('비교 결과 적용 실패:', err);
setError(err.message);
throw err;
} finally {
setLoading(false);
}
}, [loadRevisionStatus]);
useEffect(() => {
loadRevisionStatus();
}, [loadRevisionStatus]);
return {
revisionStatus,
loading,
error,
loadRevisionStatus,
createComparisonRecord,
applyComparison,
setError
};
};
/**
* 리비전 히스토리 훅
*/
export const useRevisionHistory = (jobNo) => {
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const loadHistory = useCallback(async () => {
if (!jobNo) return;
try {
setLoading(true);
setError(null);
const response = await api.get(`/revision-status/history/${jobNo}`);
if (response.data.success) {
setHistory(response.data.data || []);
}
} catch (err) {
console.error('리비전 히스토리 조회 실패:', err);
setError(err.message);
} finally {
setLoading(false);
}
}, [jobNo]);
const getRevisionByFileId = useCallback((fileId) => {
return history.find(revision => revision.file_id === fileId);
}, [history]);
const getLatestRevision = useCallback(() => {
return history.find(revision => revision.is_latest);
}, [history]);
const getPreviousRevision = useCallback((currentFileId) => {
const currentIndex = history.findIndex(revision => revision.file_id === currentFileId);
return currentIndex > -1 && currentIndex < history.length - 1
? history[currentIndex + 1]
: null;
}, [history]);
const getNextRevision = useCallback((currentFileId) => {
const currentIndex = history.findIndex(revision => revision.file_id === currentFileId);
return currentIndex > 0
? history[currentIndex - 1]
: null;
}, [history]);
useEffect(() => {
loadHistory();
}, [loadHistory]);
return {
history,
loading,
error,
loadHistory,
getRevisionByFileId,
getLatestRevision,
getPreviousRevision,
getNextRevision,
setError
};
};
/**
* 대기 중인 리비전 관리 훅
*/
export const usePendingRevisions = (jobNo = null) => {
const [pendingRevisions, setPendingRevisions] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const loadPendingRevisions = useCallback(async () => {
try {
setLoading(true);
setError(null);
const url = jobNo
? `/revision-status/pending?job_no=${jobNo}`
: '/revision-status/pending';
const response = await api.get(url);
if (response.data.success) {
setPendingRevisions(response.data.data || []);
}
} catch (err) {
console.error('대기 중인 리비전 조회 실패:', err);
setError(err.message);
} finally {
setLoading(false);
}
}, [jobNo]);
const approvePendingRevision = useCallback(async (comparisonId) => {
try {
const response = await api.post(`/revision-status/apply-comparison/${comparisonId}`);
if (response.data.success) {
// 대기 목록 새로고침
await loadPendingRevisions();
return response.data.data;
} else {
throw new Error(response.data.message || '리비전 승인 실패');
}
} catch (err) {
console.error('리비전 승인 실패:', err);
setError(err.message);
throw err;
}
}, [loadPendingRevisions]);
const rejectPendingRevision = useCallback(async (comparisonId, reason = '') => {
try {
const response = await api.post(`/revision-status/reject-comparison/${comparisonId}`, {
reason
});
if (response.data.success) {
// 대기 목록 새로고침
await loadPendingRevisions();
return response.data.data;
} else {
throw new Error(response.data.message || '리비전 거부 실패');
}
} catch (err) {
console.error('리비전 거부 실패:', err);
setError(err.message);
throw err;
}
}, [loadPendingRevisions]);
useEffect(() => {
loadPendingRevisions();
}, [loadPendingRevisions]);
return {
pendingRevisions,
loading,
error,
loadPendingRevisions,
approvePendingRevision,
rejectPendingRevision,
setError
};
};
/**
* 리비전 업로드 훅
*/
export const useRevisionUpload = (jobNo, currentFileId) => {
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [error, setError] = useState(null);
const uploadNewRevision = useCallback(async (file, revisionInfo = {}) => {
if (!file || !jobNo || !currentFileId) {
throw new Error('필수 정보가 누락되었습니다.');
}
try {
setUploading(true);
setUploadProgress(0);
setError(null);
const formData = new FormData();
formData.append('file', file);
formData.append('job_no', jobNo);
formData.append('parent_file_id', currentFileId);
// 리비전 정보 추가
Object.entries(revisionInfo).forEach(([key, value]) => {
formData.append(key, value);
});
const response = await api.post('/files/upload-revision', formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: (progressEvent) => {
const progress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setUploadProgress(progress);
}
});
if (response.data.success) {
setUploadProgress(100);
return response.data.data;
} else {
throw new Error(response.data.message || '리비전 업로드 실패');
}
} catch (err) {
console.error('리비전 업로드 실패:', err);
setError(err.message);
throw err;
} finally {
setUploading(false);
}
}, [jobNo, currentFileId]);
const validateRevisionFile = useCallback((file) => {
const errors = [];
// 파일 크기 검증 (100MB 제한)
if (file.size > 100 * 1024 * 1024) {
errors.push('파일 크기는 100MB를 초과할 수 없습니다.');
}
// 파일 형식 검증
const allowedTypes = [
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/csv'
];
if (!allowedTypes.includes(file.type)) {
errors.push('Excel 파일(.xlsx, .xls) 또는 CSV 파일만 업로드 가능합니다.');
}
return {
isValid: errors.length === 0,
errors
};
}, []);
const resetUpload = useCallback(() => {
setUploading(false);
setUploadProgress(0);
setError(null);
}, []);
return {
uploading,
uploadProgress,
error,
uploadNewRevision,
validateRevisionFile,
resetUpload,
setError
};
};
/**
* 리비전 네비게이션 훅
*/
export const useRevisionNavigation = (jobNo, currentFileId) => {
const { history } = useRevisionHistory(jobNo);
const getCurrentRevisionIndex = useCallback(() => {
return history.findIndex(revision => revision.file_id === currentFileId);
}, [history, currentFileId]);
const canNavigateToPrevious = useCallback(() => {
const currentIndex = getCurrentRevisionIndex();
return currentIndex > -1 && currentIndex < history.length - 1;
}, [getCurrentRevisionIndex, history.length]);
const canNavigateToNext = useCallback(() => {
const currentIndex = getCurrentRevisionIndex();
return currentIndex > 0;
}, [getCurrentRevisionIndex]);
const getPreviousRevision = useCallback(() => {
const currentIndex = getCurrentRevisionIndex();
return canNavigateToPrevious() ? history[currentIndex + 1] : null;
}, [getCurrentRevisionIndex, canNavigateToPrevious, history]);
const getNextRevision = useCallback(() => {
const currentIndex = getCurrentRevisionIndex();
return canNavigateToNext() ? history[currentIndex - 1] : null;
}, [getCurrentRevisionIndex, canNavigateToNext, history]);
const isLatestRevision = useCallback(() => {
const currentIndex = getCurrentRevisionIndex();
return currentIndex === 0;
}, [getCurrentRevisionIndex]);
const isFirstRevision = useCallback(() => {
const currentIndex = getCurrentRevisionIndex();
return currentIndex === history.length - 1;
}, [getCurrentRevisionIndex, history.length]);
const getRevisionPosition = useCallback(() => {
const currentIndex = getCurrentRevisionIndex();
return {
current: currentIndex + 1,
total: history.length,
isLatest: isLatestRevision(),
isFirst: isFirstRevision()
};
}, [getCurrentRevisionIndex, history.length, isLatestRevision, isFirstRevision]);
return {
canNavigateToPrevious,
canNavigateToNext,
getPreviousRevision,
getNextRevision,
isLatestRevision,
isFirstRevision,
getRevisionPosition
};
};

View File

@@ -0,0 +1,815 @@
/* 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

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

@@ -0,0 +1,593 @@
/* PIPE 이슈 관리 페이지 스타일 */
.pipe-issue-management-page {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
background-color: #f8f9fa;
min-height: 100vh;
}
/* 페이지 헤더 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header-content h1 {
margin: 0;
color: #2c3e50;
font-size: 1.8rem;
}
.header-content p {
margin: 5px 0 0 0;
color: #6c757d;
font-size: 0.9rem;
}
.header-actions .btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
text-decoration: none;
display: inline-block;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
/* 스냅샷 정보 섹션 */
.snapshot-info-section {
margin-bottom: 30px;
}
.section-header {
margin-bottom: 15px;
}
.section-header h2 {
margin: 0;
color: #2c3e50;
font-size: 1.4rem;
}
.no-snapshot-warning {
display: flex;
align-items: center;
padding: 30px;
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 8px;
margin-bottom: 20px;
}
.warning-icon {
font-size: 3rem;
margin-right: 20px;
}
.warning-content h3 {
margin: 0 0 10px 0;
color: #856404;
}
.warning-content p {
margin: 0 0 15px 0;
color: #856404;
}
.snapshot-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.snapshot-details h3 {
margin: 0 0 10px 0;
color: #2c3e50;
}
.snapshot-stats {
display: flex;
gap: 20px;
}
.stat-item {
color: #6c757d;
font-size: 0.9rem;
}
/* 이슈 통계 섹션 */
.issue-stats-section {
margin-bottom: 30px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
}
.stat-card {
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
}
.stat-card h3 {
margin: 0 0 10px 0;
color: #2c3e50;
font-size: 1.1rem;
}
.stat-number {
font-size: 2.5rem;
font-weight: bold;
color: #3498db;
margin-bottom: 10px;
}
.stat-breakdown {
display: flex;
justify-content: center;
gap: 15px;
flex-wrap: wrap;
}
.stat-breakdown .stat-item {
font-size: 0.8rem;
padding: 2px 8px;
border-radius: 12px;
color: white;
}
.stat-breakdown .stat-item.open {
background-color: #dc3545;
}
.stat-breakdown .stat-item.progress {
background-color: #ffc107;
color: #212529;
}
.stat-breakdown .stat-item.resolved {
background-color: #28a745;
}
.stat-breakdown .stat-item.critical {
background-color: #dc3545;
}
.stat-breakdown .stat-item.high {
background-color: #fd7e14;
}
/* 필터 및 액션 섹션 */
.filter-actions-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.filters {
display: flex;
gap: 15px;
}
.filter-select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
min-width: 150px;
}
.actions {
display: flex;
gap: 10px;
}
.btn-primary {
background-color: #007bff;
color: white;
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-info {
background-color: #17a2b8;
color: white;
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
}
.btn-info:hover {
background-color: #138496;
}
/* 이슈 목록 섹션 */
.drawing-issues-section,
.segment-issues-section {
margin-bottom: 30px;
}
.no-issues {
text-align: center;
padding: 40px;
background: white;
border-radius: 8px;
color: #6c757d;
}
.issues-list {
display: flex;
flex-direction: column;
gap: 15px;
}
/* 이슈 카드 */
.issue-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 20px;
border-left: 4px solid #ddd;
}
.issue-card.drawing-issue {
border-left-color: #007bff;
}
.issue-card.segment-issue {
border-left-color: #28a745;
}
.issue-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.issue-title {
display: flex;
align-items: center;
gap: 10px;
}
.area-badge,
.issue-type-badge {
background-color: #e9ecef;
color: #495057;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
}
.drawing-name,
.segment-id {
font-weight: 600;
color: #2c3e50;
}
.issue-badges {
display: flex;
gap: 8px;
}
.severity-badge,
.status-badge {
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
text-transform: uppercase;
}
.issue-content {
margin-bottom: 15px;
}
.issue-content p {
margin: 0;
color: #495057;
line-height: 1.5;
}
.issue-changes {
margin-top: 10px;
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.change-item {
background-color: #f8f9fa;
color: #495057;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
border: 1px solid #dee2e6;
}
.issue-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 15px;
border-top: 1px solid #dee2e6;
}
.issue-meta {
display: flex;
gap: 15px;
color: #6c757d;
font-size: 0.8rem;
}
.issue-actions {
display: flex;
gap: 8px;
}
.btn-sm {
padding: 6px 12px;
font-size: 0.8rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
.btn-warning {
background-color: #ffc107;
color: #212529;
}
.btn-warning:hover {
background-color: #e0a800;
}
.btn-success {
background-color: #28a745;
color: white;
}
.btn-success:hover {
background-color: #218838;
}
/* 모달 스타일 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 8px;
padding: 0;
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-content.large {
max-width: 800px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #dee2e6;
}
.modal-header h3 {
margin: 0;
color: #2c3e50;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6c757d;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close:hover {
color: #495057;
}
.modal-content form {
padding: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #495057;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
box-sizing: border-box;
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 20px;
border-top: 1px solid #dee2e6;
}
/* 리포트 스타일 */
.report-content {
padding: 20px;
}
.report-summary {
margin-bottom: 30px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 6px;
}
.report-summary h4 {
margin: 0 0 10px 0;
color: #2c3e50;
}
.report-section {
margin-bottom: 25px;
}
.report-section h4 {
margin: 0 0 15px 0;
color: #2c3e50;
font-size: 1.1rem;
}
.report-stats p {
margin: 0 0 10px 0;
font-weight: 500;
}
.report-stats .stats-breakdown {
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.report-stats .stat-item {
background-color: #e9ecef;
color: #495057;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.pipe-issue-management-page {
padding: 10px;
}
.page-header {
flex-direction: column;
gap: 15px;
text-align: center;
}
.filter-actions-section {
flex-direction: column;
gap: 20px;
}
.filters {
flex-wrap: wrap;
}
.stats-grid {
grid-template-columns: 1fr;
}
.issue-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.issue-footer {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.modal-content {
width: 95%;
margin: 10px;
}
.form-row {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,781 @@
/**
* PIPE 단관 이슈 관리 페이지
*
* 스냅샷 기반 도면별/단관별 이슈 관리 UI
*/
import React, { useState, useEffect } from 'react';
import { usePipeIssue } from '../hooks/usePipeIssue';
import { usePipeRevision } from '../hooks/usePipeRevision';
import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../components/common';
import './PipeIssueManagementPage.css';
const PipeIssueManagementPage = ({ onNavigate, jobNo, fileId }) => {
// 훅 사용
const {
loading,
error,
snapshots,
currentSnapshot,
drawingIssues,
segmentIssues,
selectedArea,
selectedDrawing,
statusFilter,
setSelectedArea,
setSelectedDrawing,
setStatusFilter,
fetchSnapshots,
createDrawingIssue,
createSegmentIssue,
updateDrawingIssueStatus,
updateSegmentIssueStatus,
generateIssueReport,
setCurrentSnapshot,
stats,
canManageIssues,
clearError
} = usePipeIssue(jobNo);
const {
getSnapshotSegments,
getAvailableAreas,
getAvailableDrawings
} = usePipeRevision(jobNo, fileId);
// 로컬 상태
const [showCreateDrawingIssue, setShowCreateDrawingIssue] = useState(false);
const [showCreateSegmentIssue, setShowCreateSegmentIssue] = useState(false);
const [selectedSegment, setSelectedSegment] = useState(null);
const [segments, setSegments] = useState([]);
const [availableAreas, setAvailableAreas] = useState([]);
const [availableDrawings, setAvailableDrawings] = useState([]);
const [issueReport, setIssueReport] = useState(null);
// 도면 이슈 생성 폼 상태
const [drawingIssueForm, setDrawingIssueForm] = useState({
area: '',
drawing_name: '',
issue_description: '',
severity: 'medium'
});
// 단관 이슈 생성 폼 상태
const [segmentIssueForm, setSegmentIssueForm] = useState({
segment_id: '',
issue_description: '',
issue_type: 'other',
length_change: '',
new_length: '',
material_change: '',
severity: 'medium'
});
// 초기 데이터 로드
useEffect(() => {
if (jobNo) {
fetchSnapshots();
}
}, [jobNo, fetchSnapshots]);
// 스냅샷 변경 시 관련 데이터 로드
useEffect(() => {
if (currentSnapshot?.snapshot_id) {
loadSnapshotData();
}
}, [currentSnapshot]);
// 스냅샷 데이터 로드
const loadSnapshotData = async () => {
if (!currentSnapshot?.snapshot_id) return;
try {
// 구역 목록 로드
const areasData = await getAvailableAreas(currentSnapshot.snapshot_id);
setAvailableAreas(areasData?.areas || []);
// 도면 목록 로드
const drawingsData = await getAvailableDrawings(currentSnapshot.snapshot_id);
setAvailableDrawings(drawingsData?.drawings || []);
// 단관 목록 로드
const segmentsData = await getSnapshotSegments(currentSnapshot.snapshot_id);
setSegments(segmentsData?.segments || []);
} catch (error) {
console.error('스냅샷 데이터 로드 실패:', error);
}
};
// 구역 변경 시 도면 목록 업데이트
useEffect(() => {
if (currentSnapshot?.snapshot_id && selectedArea) {
const loadDrawings = async () => {
const drawingsData = await getAvailableDrawings(currentSnapshot.snapshot_id, selectedArea);
setAvailableDrawings(drawingsData?.drawings || []);
};
loadDrawings();
}
}, [selectedArea, currentSnapshot, getAvailableDrawings]);
// 도면 이슈 생성 핸들러
const handleCreateDrawingIssue = async (e) => {
e.preventDefault();
if (!drawingIssueForm.area || !drawingIssueForm.drawing_name || !drawingIssueForm.issue_description) {
alert('모든 필수 필드를 입력해주세요.');
return;
}
const result = await createDrawingIssue(drawingIssueForm);
if (result) {
alert('✅ 도면 이슈가 생성되었습니다.');
setShowCreateDrawingIssue(false);
setDrawingIssueForm({
area: '',
drawing_name: '',
issue_description: '',
severity: 'medium'
});
}
};
// 단관 이슈 생성 핸들러
const handleCreateSegmentIssue = async (e) => {
e.preventDefault();
if (!segmentIssueForm.segment_id || !segmentIssueForm.issue_description) {
alert('모든 필수 필드를 입력해주세요.');
return;
}
const formData = {
...segmentIssueForm,
length_change: segmentIssueForm.length_change ? parseFloat(segmentIssueForm.length_change) : null,
new_length: segmentIssueForm.new_length ? parseFloat(segmentIssueForm.new_length) : null
};
const result = await createSegmentIssue(formData);
if (result) {
alert('✅ 단관 이슈가 생성되었습니다.');
setShowCreateSegmentIssue(false);
setSegmentIssueForm({
segment_id: '',
issue_description: '',
issue_type: 'other',
length_change: '',
new_length: '',
material_change: '',
severity: 'medium'
});
}
};
// 이슈 상태 업데이트 핸들러
const handleUpdateIssueStatus = async (issueId, issueType, newStatus) => {
const statusData = {
status: newStatus,
resolved_by: newStatus === 'resolved' ? 'user' : null
};
let result;
if (issueType === 'drawing') {
result = await updateDrawingIssueStatus(issueId, statusData);
} else {
result = await updateSegmentIssueStatus(issueId, statusData);
}
if (result) {
alert(`✅ 이슈 상태가 '${newStatus}'로 변경되었습니다.`);
}
};
// 이슈 리포트 생성 핸들러
const handleGenerateReport = async () => {
const report = await generateIssueReport();
if (report) {
setIssueReport(report);
alert('✅ 이슈 리포트가 생성되었습니다.');
}
};
// 심각도 배지 색상
const getSeverityColor = (severity) => {
switch (severity) {
case 'critical': return '#dc3545';
case 'high': return '#fd7e14';
case 'medium': return '#ffc107';
case 'low': return '#28a745';
default: return '#6c757d';
}
};
// 상태 배지 색상
const getStatusColor = (status) => {
switch (status) {
case 'open': return '#dc3545';
case 'in_progress': return '#ffc107';
case 'resolved': return '#28a745';
default: return '#6c757d';
}
};
if (loading) return <LoadingSpinner message="이슈 관리 데이터 로딩 중..." />;
return (
<div className="pipe-issue-management-page">
<div className="page-header">
<div className="header-content">
<h1>🛠 PIPE 단관 이슈 관리</h1>
<p>확정된 Cutting Plan 기준 이슈 관리 (리비전 보호)</p>
</div>
<div className="header-actions">
<button
className="btn btn-secondary"
onClick={() => onNavigate('pipe-cutting-plan')}
>
Cutting Plan으로 돌아가기
</button>
</div>
</div>
{error && <ErrorMessage message={error} onClose={clearError} />}
{/* 스냅샷 정보 */}
<div className="snapshot-info-section">
<div className="section-header">
<h2>📸 스냅샷 정보</h2>
</div>
{!canManageIssues ? (
<div className="no-snapshot-warning">
<div className="warning-icon"></div>
<div className="warning-content">
<h3>확정된 Cutting Plan이 없습니다</h3>
<p>이슈 관리를 위해서는 먼저 Cutting Plan을 확정해야 합니다.</p>
<button
className="btn btn-primary"
onClick={() => onNavigate('pipe-cutting-plan')}
>
Cutting Plan 확정하러 가기
</button>
</div>
</div>
) : (
<div className="snapshot-card">
<div className="snapshot-details">
<h3>{currentSnapshot.snapshot_name}</h3>
<div className="snapshot-stats">
<span className="stat-item">
📊 단관: {currentSnapshot.total_segments}
</span>
<span className="stat-item">
📋 도면: {currentSnapshot.total_drawings}
</span>
<span className="stat-item">
🔒 확정일: {new Date(currentSnapshot.locked_at).toLocaleDateString()}
</span>
</div>
</div>
<div className="snapshot-actions">
<button
className="btn btn-info"
onClick={handleGenerateReport}
>
📊 이슈 리포트 생성
</button>
</div>
</div>
)}
</div>
{canManageIssues && (
<>
{/* 이슈 통계 */}
<div className="issue-stats-section">
<div className="section-header">
<h2>📈 이슈 현황</h2>
</div>
<div className="stats-grid">
<div className="stat-card">
<h3>도면 이슈</h3>
<div className="stat-number">{stats.drawing.total}</div>
<div className="stat-breakdown">
<span className="stat-item open">미해결: {stats.drawing.open}</span>
<span className="stat-item progress">진행중: {stats.drawing.in_progress}</span>
<span className="stat-item resolved">완료: {stats.drawing.resolved}</span>
</div>
</div>
<div className="stat-card">
<h3>단관 이슈</h3>
<div className="stat-number">{stats.segment.total}</div>
<div className="stat-breakdown">
<span className="stat-item open">미해결: {stats.segment.open}</span>
<span className="stat-item progress">진행중: {stats.segment.in_progress}</span>
<span className="stat-item resolved">완료: {stats.segment.resolved}</span>
</div>
</div>
<div className="stat-card">
<h3>전체 이슈</h3>
<div className="stat-number">{stats.total}</div>
<div className="stat-breakdown">
<span className="stat-item critical">긴급: {stats.drawing.critical + stats.segment.critical}</span>
<span className="stat-item high">높음: {stats.drawing.high + stats.segment.high}</span>
</div>
</div>
</div>
</div>
{/* 필터 및 액션 */}
<div className="filter-actions-section">
<div className="filters">
<select
value={selectedArea}
onChange={(e) => setSelectedArea(e.target.value)}
className="filter-select"
>
<option value="">전체 구역</option>
{availableAreas.map(area => (
<option key={area} value={area}>{area}</option>
))}
</select>
<select
value={selectedDrawing}
onChange={(e) => setSelectedDrawing(e.target.value)}
className="filter-select"
>
<option value="">전체 도면</option>
{availableDrawings.map(drawing => (
<option key={drawing} value={drawing}>{drawing}</option>
))}
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="filter-select"
>
<option value="">전체 상태</option>
<option value="open">미해결</option>
<option value="in_progress">진행중</option>
<option value="resolved">완료</option>
</select>
</div>
<div className="actions">
<button
className="btn btn-primary"
onClick={() => setShowCreateDrawingIssue(true)}
>
📋 도면 이슈 등록
</button>
<button
className="btn btn-primary"
onClick={() => setShowCreateSegmentIssue(true)}
>
🔧 단관 이슈 등록
</button>
</div>
</div>
{/* 도면 이슈 목록 */}
<div className="drawing-issues-section">
<div className="section-header">
<h2>📋 도면 이슈 목록</h2>
</div>
{drawingIssues.length === 0 ? (
<div className="no-issues">
<p>등록된 도면 이슈가 없습니다.</p>
</div>
) : (
<div className="issues-list">
{drawingIssues.map(issue => (
<div key={issue.id} className="issue-card drawing-issue">
<div className="issue-header">
<div className="issue-title">
<span className="area-badge">{issue.area}</span>
<span className="drawing-name">{issue.drawing_name}</span>
</div>
<div className="issue-badges">
<span
className="severity-badge"
style={{ backgroundColor: getSeverityColor(issue.severity) }}
>
{issue.severity}
</span>
<span
className="status-badge"
style={{ backgroundColor: getStatusColor(issue.status) }}
>
{issue.status}
</span>
</div>
</div>
<div className="issue-content">
<p>{issue.issue_description}</p>
</div>
<div className="issue-footer">
<div className="issue-meta">
<span>보고자: {issue.reported_by}</span>
<span>등록일: {new Date(issue.reported_at).toLocaleDateString()}</span>
</div>
<div className="issue-actions">
{issue.status !== 'resolved' && (
<>
<button
className="btn btn-sm btn-warning"
onClick={() => handleUpdateIssueStatus(issue.id, 'drawing', 'in_progress')}
>
진행중으로 변경
</button>
<button
className="btn btn-sm btn-success"
onClick={() => handleUpdateIssueStatus(issue.id, 'drawing', 'resolved')}
>
완료로 변경
</button>
</>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* 단관 이슈 목록 */}
<div className="segment-issues-section">
<div className="section-header">
<h2>🔧 단관 이슈 목록</h2>
</div>
{segmentIssues.length === 0 ? (
<div className="no-issues">
<p>등록된 단관 이슈가 없습니다.</p>
</div>
) : (
<div className="issues-list">
{segmentIssues.map(issue => (
<div key={issue.id} className="issue-card segment-issue">
<div className="issue-header">
<div className="issue-title">
<span className="segment-id">단관 #{issue.segment_id}</span>
{issue.issue_type && (
<span className="issue-type-badge">{issue.issue_type}</span>
)}
</div>
<div className="issue-badges">
<span
className="severity-badge"
style={{ backgroundColor: getSeverityColor(issue.severity) }}
>
{issue.severity}
</span>
<span
className="status-badge"
style={{ backgroundColor: getStatusColor(issue.status) }}
>
{issue.status}
</span>
</div>
</div>
<div className="issue-content">
<p>{issue.issue_description}</p>
{(issue.length_change || issue.new_length || issue.material_change) && (
<div className="issue-changes">
{issue.length_change && (
<span className="change-item">
길이 변경: {issue.length_change > 0 ? '+' : ''}{issue.length_change}mm
</span>
)}
{issue.new_length && (
<span className="change-item">
최종 길이: {issue.new_length}mm
</span>
)}
{issue.material_change && (
<span className="change-item">
재질 변경: {issue.material_change}
</span>
)}
</div>
)}
</div>
<div className="issue-footer">
<div className="issue-meta">
<span>보고자: {issue.reported_by}</span>
<span>등록일: {new Date(issue.reported_at).toLocaleDateString()}</span>
</div>
<div className="issue-actions">
{issue.status !== 'resolved' && (
<>
<button
className="btn btn-sm btn-warning"
onClick={() => handleUpdateIssueStatus(issue.id, 'segment', 'in_progress')}
>
진행중으로 변경
</button>
<button
className="btn btn-sm btn-success"
onClick={() => handleUpdateIssueStatus(issue.id, 'segment', 'resolved')}
>
완료로 변경
</button>
</>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</>
)}
{/* 도면 이슈 생성 모달 */}
{showCreateDrawingIssue && (
<div className="modal-overlay">
<div className="modal-content">
<div className="modal-header">
<h3>📋 도면 이슈 등록</h3>
<button
className="modal-close"
onClick={() => setShowCreateDrawingIssue(false)}
>
×
</button>
</div>
<form onSubmit={handleCreateDrawingIssue}>
<div className="form-group">
<label>구역 *</label>
<select
value={drawingIssueForm.area}
onChange={(e) => setDrawingIssueForm({...drawingIssueForm, area: e.target.value})}
required
>
<option value="">구역 선택</option>
{availableAreas.map(area => (
<option key={area} value={area}>{area}</option>
))}
</select>
</div>
<div className="form-group">
<label>도면명 *</label>
<select
value={drawingIssueForm.drawing_name}
onChange={(e) => setDrawingIssueForm({...drawingIssueForm, drawing_name: e.target.value})}
required
>
<option value="">도면 선택</option>
{availableDrawings.map(drawing => (
<option key={drawing} value={drawing}>{drawing}</option>
))}
</select>
</div>
<div className="form-group">
<label>이슈 설명 *</label>
<textarea
value={drawingIssueForm.issue_description}
onChange={(e) => setDrawingIssueForm({...drawingIssueForm, issue_description: e.target.value})}
placeholder="도면 전반적인 문제점을 상세히 설명해주세요..."
rows="4"
required
/>
</div>
<div className="form-group">
<label>심각도</label>
<select
value={drawingIssueForm.severity}
onChange={(e) => setDrawingIssueForm({...drawingIssueForm, severity: e.target.value})}
>
<option value="low">낮음</option>
<option value="medium">보통</option>
<option value="high">높음</option>
<option value="critical">긴급</option>
</select>
</div>
<div className="modal-actions">
<button type="button" className="btn btn-secondary" onClick={() => setShowCreateDrawingIssue(false)}>
취소
</button>
<button type="submit" className="btn btn-primary">
등록
</button>
</div>
</form>
</div>
</div>
)}
{/* 단관 이슈 생성 모달 */}
{showCreateSegmentIssue && (
<div className="modal-overlay">
<div className="modal-content">
<div className="modal-header">
<h3>🔧 단관 이슈 등록</h3>
<button
className="modal-close"
onClick={() => setShowCreateSegmentIssue(false)}
>
×
</button>
</div>
<form onSubmit={handleCreateSegmentIssue}>
<div className="form-group">
<label>단관 선택 *</label>
<select
value={segmentIssueForm.segment_id}
onChange={(e) => setSegmentIssueForm({...segmentIssueForm, segment_id: e.target.value})}
required
>
<option value="">단관 선택</option>
{segments.map(segment => (
<option key={segment.id} value={segment.id}>
{segment.area} - {segment.drawing_name} - {segment.line_no} ({segment.length_mm}mm)
</option>
))}
</select>
</div>
<div className="form-group">
<label>이슈 유형</label>
<select
value={segmentIssueForm.issue_type}
onChange={(e) => setSegmentIssueForm({...segmentIssueForm, issue_type: e.target.value})}
>
<option value="cutting">절단</option>
<option value="installation">설치</option>
<option value="material">재질</option>
<option value="routing">라우팅</option>
<option value="other">기타</option>
</select>
</div>
<div className="form-group">
<label>이슈 설명 *</label>
<textarea
value={segmentIssueForm.issue_description}
onChange={(e) => setSegmentIssueForm({...segmentIssueForm, issue_description: e.target.value})}
placeholder="단관에서 발생한 문제점을 상세히 설명해주세요..."
rows="4"
required
/>
</div>
<div className="form-row">
<div className="form-group">
<label>길이 변경량 (mm)</label>
<input
type="number"
step="0.1"
value={segmentIssueForm.length_change}
onChange={(e) => setSegmentIssueForm({...segmentIssueForm, length_change: e.target.value})}
placeholder="예: -30 (30mm 절단)"
/>
</div>
<div className="form-group">
<label>최종 길이 (mm)</label>
<input
type="number"
step="0.1"
value={segmentIssueForm.new_length}
onChange={(e) => setSegmentIssueForm({...segmentIssueForm, new_length: e.target.value})}
placeholder="예: 1470"
/>
</div>
</div>
<div className="form-group">
<label>재질 변경</label>
<input
type="text"
value={segmentIssueForm.material_change}
onChange={(e) => setSegmentIssueForm({...segmentIssueForm, material_change: e.target.value})}
placeholder="변경된 재질 정보"
/>
</div>
<div className="form-group">
<label>심각도</label>
<select
value={segmentIssueForm.severity}
onChange={(e) => setSegmentIssueForm({...segmentIssueForm, severity: e.target.value})}
>
<option value="low">낮음</option>
<option value="medium">보통</option>
<option value="high">높음</option>
<option value="critical">긴급</option>
</select>
</div>
<div className="modal-actions">
<button type="button" className="btn btn-secondary" onClick={() => setShowCreateSegmentIssue(false)}>
취소
</button>
<button type="submit" className="btn btn-primary">
등록
</button>
</div>
</form>
</div>
</div>
)}
{/* 이슈 리포트 모달 */}
{issueReport && (
<div className="modal-overlay">
<div className="modal-content large">
<div className="modal-header">
<h3>📊 이슈 리포트</h3>
<button
className="modal-close"
onClick={() => setIssueReport(null)}
>
×
</button>
</div>
<div className="report-content">
<div className="report-summary">
<h4>전체 요약</h4>
<p> 이슈: {issueReport.total_issues}</p>
<p>생성일: {new Date(issueReport.report_generated_at).toLocaleString()}</p>
</div>
<div className="report-section">
<h4>도면 이슈 통계</h4>
<div className="report-stats">
<p> 개수: {issueReport.drawing_issues.total}</p>
<div className="stats-breakdown">
{Object.entries(issueReport.drawing_issues.by_status).map(([status, count]) => (
<span key={status} className="stat-item">
{status}: {count}
</span>
))}
</div>
</div>
</div>
<div className="report-section">
<h4>단관 이슈 통계</h4>
<div className="report-stats">
<p> 개수: {issueReport.segment_issues.total}</p>
<div className="stats-breakdown">
{Object.entries(issueReport.segment_issues.by_status).map(([status, count]) => (
<span key={status} className="stat-item">
{status}: {count}
</span>
))}
</div>
</div>
</div>
</div>
<div className="modal-actions">
<button className="btn btn-secondary" onClick={() => setIssueReport(null)}>
닫기
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PipeIssueManagementPage;

View File

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

@@ -0,0 +1,537 @@
/* 카테고리별 리비전 페이지 공통 스타일 */
.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

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

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

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

@@ -0,0 +1,666 @@
/* 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

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

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

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

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

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

View File

@@ -0,0 +1,592 @@
/**
* PIPE 시스템 공통 유틸리티
*
* 모든 PIPE 관련 컴포넌트에서 공통으로 사용되는 함수들을 모아놓은 유틸리티 모듈
*/
// ========== PIPE 상수 정의 ==========
export const PIPE_CONSTANTS = {
// 길이 관련
STANDARD_PIPE_LENGTH_MM: 6000, // 표준 파이프 길이 (6M)
CUTTING_LOSS_PER_CUT_MM: 2, // 절단당 손실 (2mm)
// 분류 관련
PIPE_CATEGORY: "PIPE",
// 끝단 처리 타입
END_PREPARATION_TYPES: {
"무개선": "PLAIN",
"한개선": "SINGLE_BEVEL",
"양개선": "DOUBLE_BEVEL"
},
// 상태 관련
REVISION_TYPES: {
NO_REVISION: "no_revision",
PRE_CUTTING_PLAN: "pre_cutting_plan",
POST_CUTTING_PLAN: "post_cutting_plan"
},
CHANGE_TYPES: {
ADDED: "added",
REMOVED: "removed",
MODIFIED: "modified",
UNCHANGED: "unchanged"
},
// UI 관련
STATUS_COLORS: {
success: '#28a745',
warning: '#ffc107',
danger: '#dc3545',
info: '#17a2b8',
primary: '#007bff'
}
};
// ========== 계산 유틸리티 ==========
export class PipeCalculator {
/**
* PIPE 구매 수량 계산
* @param {Array} materials - PIPE 자재 리스트
* @returns {Object} 계산 결과
*/
static calculatePipePurchaseQuantity(materials) {
let totalBomLength = 0;
let cuttingCount = 0;
const pipeDetails = [];
materials.forEach(material => {
const lengthMm = parseFloat(material.length || material.length_mm || 0);
const quantity = parseInt(material.quantity || 1);
if (lengthMm > 0) {
const totalLength = lengthMm * quantity;
totalBomLength += totalLength;
cuttingCount += quantity;
pipeDetails.push({
description: material.description || '',
originalDescription: material.original_description || '',
drawingName: material.drawing_name || '',
lineNo: material.line_no || '',
lengthMm: lengthMm,
quantity: quantity,
totalLength: totalLength
});
}
});
// 절단 손실 계산
const cuttingLoss = cuttingCount * PIPE_CONSTANTS.CUTTING_LOSS_PER_CUT_MM;
// 총 필요 길이
const requiredLength = totalBomLength + cuttingLoss;
// 6M 단위로 올림 계산
const pipesNeeded = requiredLength > 0 ? Math.ceil(requiredLength / PIPE_CONSTANTS.STANDARD_PIPE_LENGTH_MM) : 0;
const totalPurchaseLength = pipesNeeded * PIPE_CONSTANTS.STANDARD_PIPE_LENGTH_MM;
const wasteLength = pipesNeeded > 0 ? totalPurchaseLength - requiredLength : 0;
return {
bomQuantity: totalBomLength,
cuttingCount: cuttingCount,
cuttingLoss: cuttingLoss,
requiredLength: requiredLength,
pipesCount: pipesNeeded,
calculatedQty: totalPurchaseLength,
wasteLength: wasteLength,
utilizationRate: totalPurchaseLength > 0 ? (requiredLength / totalPurchaseLength * 100) : 0,
unit: 'mm',
pipeDetails: pipeDetails,
summary: {
totalMaterials: materials.length,
totalDrawings: new Set(materials.map(m => m.drawing_name).filter(Boolean)).size,
averageLength: materials.length > 0 ? totalBomLength / materials.length : 0
}
};
}
/**
* 길이 변화량 계산
* @param {number} oldLength - 이전 길이
* @param {number} newLength - 새로운 길이
* @returns {Object} 변화량 정보
*/
static calculateLengthDifference(oldLength, newLength) {
const difference = newLength - oldLength;
const percentage = oldLength > 0 ? (difference / oldLength * 100) : 0;
return {
oldLength: oldLength,
newLength: newLength,
difference: difference,
percentage: percentage,
changeType: difference > 0 ? 'increased' : difference < 0 ? 'decreased' : 'unchanged'
};
}
}
// ========== 비교 유틸리티 ==========
export class PipeComparator {
/**
* 단관 데이터 비교
* @param {Array} oldSegments - 이전 단관 데이터
* @param {Array} newSegments - 새로운 단관 데이터
* @returns {Object} 비교 결과
*/
static comparePipeSegments(oldSegments, newSegments) {
// 키 생성 함수
const createSegmentKey = (segment) => {
return [
segment.drawing_name || '',
segment.material_grade || '',
segment.length || 0,
segment.end_preparation || '무개선'
].join('|');
};
// 기존 데이터를 키로 매핑
const oldMap = {};
oldSegments.forEach(segment => {
const key = createSegmentKey(segment);
if (!oldMap[key]) oldMap[key] = [];
oldMap[key].push(segment);
});
// 새로운 데이터를 키로 매핑
const newMap = {};
newSegments.forEach(segment => {
const key = createSegmentKey(segment);
if (!newMap[key]) newMap[key] = [];
newMap[key].push(segment);
});
// 비교 결과 생성
const changes = {
added: [],
removed: [],
modified: [],
unchanged: []
};
const allKeys = new Set([...Object.keys(oldMap), ...Object.keys(newMap)]);
allKeys.forEach(key => {
const oldCount = oldMap[key] ? oldMap[key].length : 0;
const newCount = newMap[key] ? newMap[key].length : 0;
if (oldCount === 0) {
// 새로 추가된 항목
newMap[key].forEach(segment => {
changes.added.push({
...segment,
changeType: 'added',
quantityChange: newCount
});
});
} else if (newCount === 0) {
// 삭제된 항목
oldMap[key].forEach(segment => {
changes.removed.push({
...segment,
changeType: 'removed',
quantityChange: -oldCount
});
});
} else if (oldCount !== newCount) {
// 수량이 변경된 항목
const baseSegment = newMap[key][0] || oldMap[key][0];
changes.modified.push({
...baseSegment,
changeType: 'modified',
oldQuantity: oldCount,
newQuantity: newCount,
quantityChange: newCount - oldCount
});
} else {
// 변경되지 않은 항목
const baseSegment = newMap[key][0];
changes.unchanged.push({
...baseSegment,
changeType: 'unchanged',
quantity: oldCount
});
}
});
// 통계 생성
const stats = {
totalOld: oldSegments.length,
totalNew: newSegments.length,
addedCount: changes.added.length,
removedCount: changes.removed.length,
modifiedCount: changes.modified.length,
unchangedCount: changes.unchanged.length,
changedDrawings: new Set([
...changes.added,
...changes.removed,
...changes.modified
].map(item => item.drawing_name).filter(Boolean)).size
};
return {
changes: changes,
statistics: stats,
hasChanges: stats.addedCount + stats.removedCount + stats.modifiedCount > 0
};
}
}
// ========== 검증 유틸리티 ==========
export class PipeValidator {
/**
* PIPE 데이터 유효성 검증
* @param {Object} pipeData - 검증할 PIPE 데이터
* @returns {Object} 검증 결과
*/
static validatePipeData(pipeData) {
const errors = [];
const warnings = [];
// 필수 필드 검증
const requiredFields = ['drawing_name', 'material_grade', 'length'];
requiredFields.forEach(field => {
if (!pipeData[field]) {
errors.push(`필수 필드 누락: ${field}`);
}
});
// 길이 검증
const length = parseFloat(pipeData.length || 0);
if (length <= 0) {
errors.push("길이는 0보다 커야 합니다");
} else if (length > 20000) { // 20m 초과시 경고
warnings.push(`길이가 비정상적으로 큽니다: ${length}mm`);
}
// 수량 검증
const quantity = parseInt(pipeData.quantity || 1);
if (quantity <= 0) {
errors.push("수량은 0보다 커야 합니다");
}
// 도면명 검증
const drawingName = pipeData.drawing_name || '';
if (drawingName === 'UNKNOWN') {
warnings.push("도면명이 지정되지 않았습니다");
}
return {
isValid: errors.length === 0,
errors: errors,
warnings: warnings,
errorCount: errors.length,
warningCount: warnings.length
};
}
/**
* Cutting Plan 데이터 전체 검증
* @param {Array} cuttingPlan - Cutting Plan 데이터 리스트
* @returns {Object} 검증 결과
*/
static validateCuttingPlanData(cuttingPlan) {
const totalErrors = [];
const totalWarnings = [];
let validItems = 0;
cuttingPlan.forEach((item, index) => {
const validation = PipeValidator.validatePipeData(item);
if (validation.isValid) {
validItems++;
} else {
validation.errors.forEach(error => {
totalErrors.push(`항목 ${index + 1}: ${error}`);
});
}
validation.warnings.forEach(warning => {
totalWarnings.push(`항목 ${index + 1}: ${warning}`);
});
});
return {
isValid: totalErrors.length === 0,
totalItems: cuttingPlan.length,
validItems: validItems,
invalidItems: cuttingPlan.length - validItems,
errors: totalErrors,
warnings: totalWarnings,
validationRate: cuttingPlan.length > 0 ? (validItems / cuttingPlan.length * 100) : 0
};
}
}
// ========== 포맷팅 유틸리티 ==========
export class PipeFormatter {
/**
* 길이 포맷팅
* @param {number} lengthMm - 길이 (mm)
* @param {string} unit - 표시 단위
* @returns {string} 포맷된 길이 문자열
*/
static formatLength(lengthMm, unit = 'mm') {
if (unit === 'm') {
return `${(lengthMm / 1000).toFixed(3)}m`;
} else if (unit === 'mm') {
return `${Math.round(lengthMm)}mm`;
} else {
return `${lengthMm}`;
}
}
/**
* PIPE 설명 포맷팅
* @param {Object} pipeData - PIPE 데이터
* @returns {string} 포맷된 설명
*/
static formatPipeDescription(pipeData) {
const parts = [];
if (pipeData.material_grade) {
parts.push(pipeData.material_grade);
}
if (pipeData.main_nom) {
parts.push(pipeData.main_nom);
}
if (pipeData.schedule) {
parts.push(pipeData.schedule);
}
if (pipeData.length) {
parts.push(PipeFormatter.formatLength(pipeData.length));
}
return parts.length > 0 ? parts.join(' ') : 'PIPE';
}
/**
* 변경사항 요약 포맷팅
* @param {Object} changes - 변경사항 딕셔너리
* @returns {string} 포맷된 요약 문자열
*/
static formatChangeSummary(changes) {
const summaryParts = [];
if (changes.added && changes.added.length > 0) {
summaryParts.push(`추가 ${changes.added.length}`);
}
if (changes.removed && changes.removed.length > 0) {
summaryParts.push(`삭제 ${changes.removed.length}`);
}
if (changes.modified && changes.modified.length > 0) {
summaryParts.push(`수정 ${changes.modified.length}`);
}
if (summaryParts.length === 0) {
return "변경사항 없음";
}
return summaryParts.join(', ');
}
/**
* 숫자를 천 단위 콤마로 포맷팅
* @param {number} number - 포맷할 숫자
* @returns {string} 포맷된 숫자 문자열
*/
static formatNumber(number) {
return new Intl.NumberFormat('ko-KR').format(number);
}
/**
* 백분율 포맷팅
* @param {number} value - 백분율 값
* @param {number} decimals - 소수점 자리수
* @returns {string} 포맷된 백분율 문자열
*/
static formatPercentage(value, decimals = 1) {
return `${value.toFixed(decimals)}%`;
}
}
// ========== UI 유틸리티 ==========
export class PipeUIUtils {
/**
* 변경 타입에 따른 색상 반환
* @param {string} changeType - 변경 타입
* @returns {string} CSS 색상 코드
*/
static getChangeTypeColor(changeType) {
const colors = {
added: PIPE_CONSTANTS.STATUS_COLORS.success,
removed: PIPE_CONSTANTS.STATUS_COLORS.danger,
modified: PIPE_CONSTANTS.STATUS_COLORS.warning,
unchanged: PIPE_CONSTANTS.STATUS_COLORS.info
};
return colors[changeType] || PIPE_CONSTANTS.STATUS_COLORS.primary;
}
/**
* 변경 타입에 따른 아이콘 반환
* @param {string} changeType - 변경 타입
* @returns {string} 아이콘 클래스명
*/
static getChangeTypeIcon(changeType) {
const icons = {
added: '',
removed: '',
modified: '🔄',
unchanged: '✅'
};
return icons[changeType] || '❓';
}
/**
* 상태에 따른 배지 스타일 생성
* @param {string} status - 상태
* @returns {Object} 스타일 객체
*/
static createStatusBadge(status) {
const styles = {
success: {
backgroundColor: PIPE_CONSTANTS.STATUS_COLORS.success,
color: 'white'
},
warning: {
backgroundColor: PIPE_CONSTANTS.STATUS_COLORS.warning,
color: 'black'
},
danger: {
backgroundColor: PIPE_CONSTANTS.STATUS_COLORS.danger,
color: 'white'
},
info: {
backgroundColor: PIPE_CONSTANTS.STATUS_COLORS.info,
color: 'white'
}
};
return {
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: 'bold',
display: 'inline-block',
...styles[status]
};
}
}
// ========== 데이터 변환 유틸리티 ==========
export class PipeDataTransformer {
/**
* API 응답 데이터를 UI용 데이터로 변환
* @param {Object} apiData - API 응답 데이터
* @returns {Object} 변환된 데이터
*/
static transformApiDataForUI(apiData) {
if (!apiData) return null;
return {
...apiData,
formattedLength: PipeFormatter.formatLength(apiData.length || 0),
formattedDescription: PipeFormatter.formatPipeDescription(apiData),
changeTypeColor: PipeUIUtils.getChangeTypeColor(apiData.change_type),
changeTypeIcon: PipeUIUtils.getChangeTypeIcon(apiData.change_type)
};
}
/**
* UI 데이터를 API 전송용 데이터로 변환
* @param {Object} uiData - UI 데이터
* @returns {Object} 변환된 데이터
*/
static transformUIDataForAPI(uiData) {
const apiData = { ...uiData };
// UI 전용 필드 제거
delete apiData.formattedLength;
delete apiData.formattedDescription;
delete apiData.changeTypeColor;
delete apiData.changeTypeIcon;
// 숫자 타입 변환
if (apiData.length) {
apiData.length = parseFloat(apiData.length);
}
if (apiData.quantity) {
apiData.quantity = parseInt(apiData.quantity);
}
return apiData;
}
}
// ========== 로깅 유틸리티 ==========
export class PipeLogger {
/**
* PIPE 작업 로깅
* @param {string} operation - 작업 유형
* @param {string} jobNo - 작업 번호
* @param {Object} details - 상세 정보
*/
static logPipeOperation(operation, jobNo, details = {}) {
const message = `🔧 PIPE ${operation} | Job: ${jobNo}`;
if (Object.keys(details).length > 0) {
const detailParts = Object.entries(details).map(([key, value]) => `${key}: ${value}`);
console.log(`${message} | ${detailParts.join(', ')}`);
} else {
console.log(message);
}
}
/**
* PIPE 오류 로깅
* @param {string} operation - 작업 유형
* @param {string} jobNo - 작업 번호
* @param {Error} error - 오류 객체
* @param {Object} context - 컨텍스트 정보
*/
static logPipeError(operation, jobNo, error, context = {}) {
const message = `❌ PIPE ${operation} 실패 | Job: ${jobNo} | Error: ${error.message}`;
if (Object.keys(context).length > 0) {
const contextParts = Object.entries(context).map(([key, value]) => `${key}: ${value}`);
console.error(`${message} | Context: ${contextParts.join(', ')}`);
} else {
console.error(message);
}
console.error(error);
}
}
// 기본 export
export default {
PIPE_CONSTANTS,
PipeCalculator,
PipeComparator,
PipeValidator,
PipeFormatter,
PipeUIUtils,
PipeDataTransformer,
PipeLogger
};