-
페이지를 찾을 수 없습니다
-
+
+
+
❓
+
알 수 없는 페이지입니다.
+
+
);
}
};
- // 로딩 중
- if (isLoading) {
- return (
-
- );
- }
+ const handleLogout = () => {
+ localStorage.removeItem('access_token');
+ localStorage.removeItem('user_data');
+ setIsAuthenticated(false);
+ setUser(null);
+ setCurrentPage('dashboard');
+ window.location.reload();
+ };
- // 로그인하지 않은 경우
- if (!isAuthenticated) {
- return
;
- }
+ const handleLoginSuccess = (userData) => {
+ setIsAuthenticated(true);
+ setUser(userData);
+ setIsLoading(false);
+ loadPendingSignups();
+ loadProjects();
+ };
- // 메인 애플리케이션
return (
-
- {renderCurrentPage()}
-
+ {isLoading ? (
+
+ ) : !isAuthenticated ? (
+
+ ) : (
+
+ {/* 상단 헤더 */}
+
+
+
+ TK-MP BOM Management System
+
+
+ {user?.name || user?.username}님 환영합니다
+
+
+
+ {/* 사용자 메뉴 */}
+
+
+
+ {/* 페이지 컨텐츠 */}
+ {renderCurrentPage()}
+
+ )}
);
}
diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx
deleted file mode 100644
index baf94b3..0000000
--- a/frontend/src/components/ErrorBoundary.jsx
+++ /dev/null
@@ -1,268 +0,0 @@
-import React from 'react';
-import errorLogger from '../utils/errorLogger';
-
-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) {
- // 오류 정보를 상태에 저장
- this.setState({
- error: error,
- errorInfo: errorInfo
- });
-
- // 오류 로깅
- errorLogger.logError({
- type: 'react_error_boundary',
- message: error.message,
- stack: error.stack,
- componentStack: errorInfo.componentStack,
- timestamp: new Date().toISOString(),
- url: window.location.href,
- props: this.props.errorContext || {}
- });
-
- console.error('ErrorBoundary caught an error:', error, errorInfo);
- }
-
- handleReload = () => {
- window.location.reload();
- };
-
- handleGoHome = () => {
- window.location.href = '/';
- };
-
- handleReportError = () => {
- const errorReport = {
- error: this.state.error?.message,
- stack: this.state.error?.stack,
- componentStack: this.state.errorInfo?.componentStack,
- url: window.location.href,
- timestamp: new Date().toISOString(),
- userAgent: navigator.userAgent
- };
-
- // 오류 보고서를 클립보드에 복사
- navigator.clipboard.writeText(JSON.stringify(errorReport, null, 2))
- .then(() => {
- alert('오류 정보가 클립보드에 복사되었습니다.');
- })
- .catch(() => {
- // 클립보드 복사 실패 시 텍스트 영역에 표시
- const textarea = document.createElement('textarea');
- textarea.value = JSON.stringify(errorReport, null, 2);
- document.body.appendChild(textarea);
- textarea.select();
- document.execCommand('copy');
- document.body.removeChild(textarea);
- alert('오류 정보가 클립보드에 복사되었습니다.');
- });
- };
-
- render() {
- if (this.state.hasError) {
- return (
-
-
-
- 😵
-
-
-
- 앗! 문제가 발생했습니다
-
-
-
- 예상치 못한 오류가 발생했습니다.
- 이 문제는 자동으로 개발팀에 보고되었습니다.
-
-
-
-
-
-
-
-
-
-
- {/* 개발 환경에서만 상세 오류 정보 표시 */}
- {process.env.NODE_ENV === 'development' && this.state.error && (
-
-
- 개발자 정보 (클릭하여 펼치기)
-
-
-
-
오류 메시지:
-
- {this.state.error.message}
-
-
-
-
-
스택 트레이스:
-
- {this.state.error.stack}
-
-
-
- {this.state.errorInfo?.componentStack && (
-
-
컴포넌트 스택:
-
- {this.state.errorInfo.componentStack}
-
-
- )}
-
- )}
-
-
- 💡 도움말: 문제가 계속 발생하면 페이지를 새로고침하거나
- 브라우저 캐시를 삭제해보세요.
-
-
-
- );
- }
-
- return this.props.children;
- }
-}
-
-export default ErrorBoundary;
diff --git a/frontend/src/components/bom/index.js b/frontend/src/components/bom/index.js
new file mode 100644
index 0000000..e30a8b0
--- /dev/null
+++ b/frontend/src/components/bom/index.js
@@ -0,0 +1,3 @@
+// BOM Components
+export * from './materials';
+export * from './shared';
diff --git a/frontend/src/components/bom/materials/BoltMaterialsView.jsx b/frontend/src/components/bom/materials/BoltMaterialsView.jsx
new file mode 100644
index 0000000..8c3d1f3
--- /dev/null
+++ b/frontend/src/components/bom/materials/BoltMaterialsView.jsx
@@ -0,0 +1,460 @@
+import React, { useState } from 'react';
+import { exportMaterialsToExcel } from '../../../utils/excelExport';
+import api from '../../../api';
+import { FilterableHeader } from '../shared';
+
+const BoltMaterialsView = ({
+ materials,
+ selectedMaterials,
+ setSelectedMaterials,
+ userRequirements,
+ setUserRequirements,
+ purchasedMaterials,
+ fileId,
+ user
+}) => {
+ const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
+ const [columnFilters, setColumnFilters] = useState({});
+ const [showFilterDropdown, setShowFilterDropdown] = useState(null);
+ // 볼트 추가요구사항 추출 함수
+ const extractBoltAdditionalRequirements = (description) => {
+ const additionalReqs = [];
+
+ // 표면처리 패턴 확인
+ const surfacePatterns = {
+ 'ELEC.GALV': 'ELEC.GALV',
+ 'ELEC GALV': 'ELEC.GALV',
+ 'GALVANIZED': 'GALVANIZED',
+ 'GALV': 'GALV',
+ 'HOT DIP GALV': 'HDG',
+ 'HDG': 'HDG',
+ 'ZINC PLATED': 'ZINC PLATED',
+ 'ZINC': 'ZINC',
+ 'PLAIN': 'PLAIN'
+ };
+
+ for (const [pattern, treatment] of Object.entries(surfacePatterns)) {
+ if (description.includes(pattern)) {
+ additionalReqs.push(treatment);
+ break; // 첫 번째 매치만 사용
+ }
+ }
+
+ return additionalReqs.join(', ') || '-';
+ };
+
+ const parseBoltInfo = (material) => {
+ const qty = Math.round(material.quantity || 0);
+ const safetyQty = Math.ceil(qty * 1.05); // 5% 여유율
+ const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수
+
+ // 볼트 상세 정보 우선 사용
+ const boltDetails = material.bolt_details || {};
+
+ // 길이 정보 (bolt_details 우선, 없으면 원본 설명에서 추출)
+ let boltLength = '-';
+ if (boltDetails.length && boltDetails.length !== '-') {
+ boltLength = boltDetails.length;
+ } else {
+ // 원본 설명에서 길이 추출
+ const description = material.original_description || '';
+ const lengthPatterns = [
+ /(\d+(?:\.\d+)?)\s*LG/i, // 75 LG, 90.0000 LG
+ /(\d+(?:\.\d+)?)\s*mm/i, // 50mm
+ /(\d+(?:\.\d+)?)\s*MM/i, // 50MM
+ /LG[,\s]*(\d+(?:\.\d+)?)/i // LG, 75 형태
+ ];
+
+ for (const pattern of lengthPatterns) {
+ const match = description.match(pattern);
+ if (match) {
+ let lengthValue = match[1];
+ // 소수점 제거 (145.0000 → 145)
+ if (lengthValue.includes('.') && lengthValue.endsWith('.0000')) {
+ lengthValue = lengthValue.split('.')[0];
+ } else if (lengthValue.includes('.') && /\.0+$/.test(lengthValue)) {
+ lengthValue = lengthValue.split('.')[0];
+ }
+ boltLength = `${lengthValue}mm`;
+ break;
+ }
+ }
+ }
+
+ // 재질 정보 (bolt_details 우선, 없으면 기본 필드 사용)
+ let boltGrade = '-';
+ if (boltDetails.material_standard && boltDetails.material_grade) {
+ // bolt_details에서 완전한 재질 정보 구성
+ if (boltDetails.material_grade !== 'UNKNOWN' && boltDetails.material_grade !== boltDetails.material_standard) {
+ boltGrade = `${boltDetails.material_standard} ${boltDetails.material_grade}`;
+ } else {
+ boltGrade = boltDetails.material_standard;
+ }
+ } else if (material.full_material_grade && material.full_material_grade !== '-') {
+ boltGrade = material.full_material_grade;
+ } else if (material.material_grade && material.material_grade !== '-') {
+ boltGrade = material.material_grade;
+ }
+
+ // 볼트 타입 (PSV_BOLT, LT_BOLT 등)
+ let boltSubtype = 'BOLT_GENERAL';
+ if (boltDetails.bolt_type && boltDetails.bolt_type !== 'UNKNOWN') {
+ boltSubtype = boltDetails.bolt_type;
+ } else {
+ // 원본 설명에서 특수 볼트 타입 추출
+ const description = material.original_description || '';
+ const upperDesc = description.toUpperCase();
+ if (upperDesc.includes('PSV')) {
+ boltSubtype = 'PSV_BOLT';
+ } else if (upperDesc.includes('LT')) {
+ boltSubtype = 'LT_BOLT';
+ } else if (upperDesc.includes('CK')) {
+ boltSubtype = 'CK_BOLT';
+ }
+ }
+
+ // 추가요구사항 추출 (ELEC.GALV 등)
+ const additionalReq = extractBoltAdditionalRequirements(material.original_description || '');
+
+ return {
+ type: 'BOLT',
+ subtype: boltSubtype,
+ size: material.size_spec || material.main_nom || '-',
+ pressure: '-', // 볼트는 압력 등급 없음
+ schedule: boltLength, // 길이 정보
+ grade: boltGrade,
+ additionalReq: additionalReq, // 추가요구사항
+ quantity: purchaseQty,
+ unit: 'SETS'
+ };
+ };
+
+ // 정렬 처리
+ const handleSort = (key) => {
+ let direction = 'asc';
+ if (sortConfig.key === key && sortConfig.direction === 'asc') {
+ direction = 'desc';
+ }
+ setSortConfig({ key, direction });
+ };
+
+ // 필터링된 및 정렬된 자재 목록
+ const getFilteredAndSortedMaterials = () => {
+ let filtered = materials.filter(material => {
+ return Object.entries(columnFilters).every(([key, filterValue]) => {
+ if (!filterValue) return true;
+ const info = parseBoltInfo(material);
+ const value = info[key]?.toString().toLowerCase() || '';
+ return value.includes(filterValue.toLowerCase());
+ });
+ });
+
+ if (sortConfig.key) {
+ filtered.sort((a, b) => {
+ const aInfo = parseBoltInfo(a);
+ const bInfo = parseBoltInfo(b);
+ const aValue = aInfo[sortConfig.key] || '';
+ const bValue = bInfo[sortConfig.key] || '';
+
+ if (sortConfig.direction === 'asc') {
+ return aValue > bValue ? 1 : -1;
+ } else {
+ return aValue < bValue ? 1 : -1;
+ }
+ });
+ }
+
+ return filtered;
+ };
+
+ // 전체 선택/해제 (구매신청된 자재 제외)
+ const handleSelectAll = () => {
+ const filteredMaterials = getFilteredAndSortedMaterials();
+ const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
+
+ if (selectedMaterials.size === selectableMaterials.length) {
+ setSelectedMaterials(new Set());
+ } else {
+ setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
+ }
+ };
+
+ // 개별 선택 (구매신청된 자재는 선택 불가)
+ const handleMaterialSelect = (materialId) => {
+ if (purchasedMaterials.has(materialId)) {
+ return; // 구매신청된 자재는 선택 불가
+ }
+
+ const newSelected = new Set(selectedMaterials);
+ if (newSelected.has(materialId)) {
+ newSelected.delete(materialId);
+ } else {
+ newSelected.add(materialId);
+ }
+ setSelectedMaterials(newSelected);
+ };
+
+ // 엑셀 내보내기
+ const handleExportToExcel = async () => {
+ const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
+ if (selectedMaterialsData.length === 0) {
+ alert('내보낼 자재를 선택해주세요.');
+ return;
+ }
+
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
+ const excelFileName = `BOLT_Materials_${timestamp}.xlsx`;
+
+ const dataWithRequirements = selectedMaterialsData.map(material => ({
+ ...material,
+ user_requirement: userRequirements[material.id] || ''
+ }));
+
+ try {
+ await api.post('/files/save-excel', {
+ file_id: fileId,
+ category: 'BOLT',
+ materials: dataWithRequirements,
+ filename: excelFileName,
+ user_id: user?.id
+ });
+
+ exportMaterialsToExcel(dataWithRequirements, excelFileName, {
+ category: 'BOLT',
+ filename: excelFileName,
+ uploadDate: new Date().toLocaleDateString()
+ });
+
+ alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
+ } catch (error) {
+ console.error('엑셀 저장 실패:', error);
+ exportMaterialsToExcel(dataWithRequirements, excelFileName, {
+ category: 'BOLT',
+ filename: excelFileName,
+ uploadDate: new Date().toLocaleDateString()
+ });
+ }
+ };
+
+ const filteredMaterials = getFilteredAndSortedMaterials();
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+ Bolt Materials
+
+
+ {filteredMaterials.length} items • {selectedMaterials.size} selected
+
+
+
+
+
+
+
+
+
+
+ {/* 테이블 */}
+
+ {/* 헤더 */}
+
+
+ {
+ const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
+ return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
+ })()}
+ onChange={handleSelectAll}
+ style={{ cursor: 'pointer' }}
+ />
+
+
Type
+
Size
+
Pressure
+
Length
+
Material Grade
+
Quantity
+
Unit
+
User Requirement
+
+
+ {/* 데이터 행들 */}
+
+ {filteredMaterials.map((material, index) => {
+ const info = parseBoltInfo(material);
+ const isSelected = selectedMaterials.has(material.id);
+ const isPurchased = purchasedMaterials.has(material.id);
+
+ return (
+
{
+ if (!isSelected && !isPurchased) {
+ e.target.style.background = '#f8fafc';
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (!isSelected && !isPurchased) {
+ e.target.style.background = 'white';
+ }
+ }}
+ >
+
+ handleMaterialSelect(material.id)}
+ disabled={isPurchased}
+ style={{
+ cursor: isPurchased ? 'not-allowed' : 'pointer',
+ opacity: isPurchased ? 0.5 : 1
+ }}
+ />
+
+
+ {info.subtype}
+ {isPurchased && (
+
+ PURCHASED
+
+ )}
+
+
+ {info.size}
+
+
+ {info.pressure}
+
+
+ {info.schedule}
+
+
+ {info.grade}
+
+
+ {info.quantity}
+
+
+ {info.unit}
+
+
+ setUserRequirements({
+ ...userRequirements,
+ [material.id]: e.target.value
+ })}
+ placeholder="Enter requirement..."
+ style={{
+ width: '100%',
+ padding: '6px 8px',
+ border: '1px solid #d1d5db',
+ borderRadius: '4px',
+ fontSize: '12px'
+ }}
+ />
+
+
+ );
+ })}
+
+
+
+ {filteredMaterials.length === 0 && (
+
+
🔩
+
+ No Bolt Materials Found
+
+
+ {Object.keys(columnFilters).some(key => columnFilters[key])
+ ? 'Try adjusting your filters'
+ : 'No bolt materials available in this BOM'}
+
+
+ )}
+
+ );
+};
+
+export default BoltMaterialsView;
diff --git a/frontend/src/components/bom/materials/FittingMaterialsView.jsx b/frontend/src/components/bom/materials/FittingMaterialsView.jsx
new file mode 100644
index 0000000..5c3fd5b
--- /dev/null
+++ b/frontend/src/components/bom/materials/FittingMaterialsView.jsx
@@ -0,0 +1,666 @@
+import React, { useState } from 'react';
+import { exportMaterialsToExcel } from '../../../utils/excelExport';
+import api from '../../../api';
+import { FilterableHeader, MaterialTable } from '../shared';
+
+const FittingMaterialsView = ({
+ materials,
+ selectedMaterials,
+ setSelectedMaterials,
+ userRequirements,
+ setUserRequirements,
+ purchasedMaterials,
+ fileId,
+ user,
+ onNavigate
+}) => {
+ const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
+ const [columnFilters, setColumnFilters] = useState({});
+ const [showFilterDropdown, setShowFilterDropdown] = useState(null);
+
+ // 니플 끝단 정보 추출 (기존 로직 복원)
+ const extractNippleEndInfo = (description) => {
+ const descUpper = description.toUpperCase();
+
+ // 니플 끝단 패턴들 (기존 NewMaterialsPage와 동일)
+ const endPatterns = {
+ 'PBE': 'PBE', // Plain Both End
+ 'BBE': 'BBE', // Bevel Both End
+ 'POE': 'POE', // Plain One End
+ 'BOE': 'BOE', // Bevel One End
+ 'TOE': 'TOE', // Thread One End
+ 'SW X NPT': 'SW×NPT', // Socket Weld x NPT
+ 'SW X SW': 'SW×SW', // Socket Weld x Socket Weld
+ 'NPT X NPT': 'NPT×NPT', // NPT x NPT
+ 'BOTH END THREADED': 'B.E.T',
+ 'B.E.T': 'B.E.T',
+ 'ONE END THREADED': 'O.E.T',
+ 'O.E.T': 'O.E.T',
+ 'THREADED': 'THD'
+ };
+
+ for (const [pattern, display] of Object.entries(endPatterns)) {
+ if (descUpper.includes(pattern)) {
+ return display;
+ }
+ }
+
+ return '';
+ };
+
+ // 피팅 정보 파싱 (기존 상세 로직 복원)
+ const parseFittingInfo = (material) => {
+ const fittingDetails = material.fitting_details || {};
+ const classificationDetails = material.classification_details || {};
+
+ // 개선된 분류기 결과 우선 사용
+ const fittingTypeInfo = classificationDetails.fitting_type || {};
+ const scheduleInfo = classificationDetails.schedule_info || {};
+
+ // 기존 필드와 새 필드 통합
+ const fittingType = fittingTypeInfo.type || fittingDetails.fitting_type || '';
+ const fittingSubtype = fittingTypeInfo.subtype || fittingDetails.fitting_subtype || '';
+ const mainSchedule = scheduleInfo.main_schedule || fittingDetails.schedule || '';
+ const redSchedule = scheduleInfo.red_schedule || '';
+ const hasDifferentSchedules = scheduleInfo.has_different_schedules || false;
+
+ const description = material.original_description || '';
+
+ // 피팅 타입별 상세 표시
+ let displayType = '';
+
+ // 개선된 분류기 결과 우선 표시
+ if (fittingType === 'TEE' && fittingSubtype === 'REDUCING') {
+ displayType = 'TEE REDUCING';
+ } else if (fittingType === 'REDUCER' && fittingSubtype === 'CONCENTRIC') {
+ displayType = 'REDUCER CONC';
+ } else if (fittingType === 'REDUCER' && fittingSubtype === 'ECCENTRIC') {
+ displayType = 'REDUCER ECC';
+ } else if (description.toUpperCase().includes('TEE RED')) {
+ displayType = 'TEE REDUCING';
+ } else if (description.toUpperCase().includes('RED CONC')) {
+ displayType = 'REDUCER CONC';
+ } else if (description.toUpperCase().includes('RED ECC')) {
+ displayType = 'REDUCER ECC';
+ } else if (description.toUpperCase().includes('CAP')) {
+ if (description.includes('NPT(F)')) {
+ displayType = 'CAP NPT(F)';
+ } else if (description.includes('SW')) {
+ displayType = 'CAP SW';
+ } else if (description.includes('BW')) {
+ displayType = 'CAP BW';
+ } else {
+ displayType = 'CAP';
+ }
+ } else if (description.toUpperCase().includes('PLUG')) {
+ if (description.toUpperCase().includes('HEX')) {
+ if (description.includes('NPT(M)')) {
+ displayType = 'HEX PLUG NPT(M)';
+ } else {
+ displayType = 'HEX PLUG';
+ }
+ } else if (description.includes('NPT(M)')) {
+ displayType = 'PLUG NPT(M)';
+ } else if (description.includes('NPT')) {
+ displayType = 'PLUG NPT';
+ } else {
+ displayType = 'PLUG';
+ }
+ } else if (fittingType === 'NIPPLE') {
+ const length = fittingDetails.length_mm || fittingDetails.avg_length_mm;
+ const endInfo = extractNippleEndInfo(description);
+
+ let nippleType = 'NIPPLE';
+ if (length) nippleType += ` ${length}mm`;
+ if (endInfo) nippleType += ` ${endInfo}`;
+
+ displayType = nippleType;
+ } else if (fittingType === 'ELBOW') {
+ let elbowDetails = [];
+
+ // 각도 정보 추출
+ if (fittingSubtype.includes('90DEG') || description.includes('90') || description.includes('90°')) {
+ elbowDetails.push('90°');
+ } else if (fittingSubtype.includes('45DEG') || description.includes('45') || description.includes('45°')) {
+ elbowDetails.push('45°');
+ }
+
+ // 반경 정보 추출 (Long Radius / Short Radius)
+ if (fittingSubtype.includes('LONG_RADIUS') || description.toUpperCase().includes('LR') || description.toUpperCase().includes('LONG RADIUS')) {
+ elbowDetails.push('LR');
+ } else if (fittingSubtype.includes('SHORT_RADIUS') || description.toUpperCase().includes('SR') || description.toUpperCase().includes('SHORT RADIUS')) {
+ elbowDetails.push('SR');
+ }
+
+ // 연결 방식
+ if (description.includes('SW')) {
+ elbowDetails.push('SW');
+ } else if (description.includes('BW')) {
+ elbowDetails.push('BW');
+ }
+
+ // 기본값 설정 (각도가 없으면 90도로 가정)
+ if (!elbowDetails.some(detail => detail.includes('°'))) {
+ elbowDetails.unshift('90°');
+ }
+
+ displayType = `ELBOW ${elbowDetails.join(' ')}`.trim();
+ } else if (fittingType === 'TEE') {
+ // TEE 타입과 연결 방식 상세 표시
+ let teeDetails = [];
+
+ // 등경/축소 타입
+ if (fittingSubtype === 'EQUAL' || description.toUpperCase().includes('TEE EQ')) {
+ teeDetails.push('EQ');
+ } else if (fittingSubtype === 'REDUCING' || description.toUpperCase().includes('TEE RED')) {
+ teeDetails.push('RED');
+ }
+
+ // 연결 방식
+ if (description.includes('SW')) {
+ teeDetails.push('SW');
+ } else if (description.includes('BW')) {
+ teeDetails.push('BW');
+ }
+
+ displayType = `TEE ${teeDetails.join(' ')}`.trim();
+ } else if (fittingType === 'REDUCER') {
+ const reducerType = fittingSubtype === 'CONCENTRIC' ? 'CONC' : fittingSubtype === 'ECCENTRIC' ? 'ECC' : '';
+ const sizes = fittingDetails.reduced_size ? `${material.size_spec}×${fittingDetails.reduced_size}` : material.size_spec;
+ displayType = `RED ${reducerType} ${sizes}`.trim();
+ } else if (fittingType === 'SWAGE') {
+ const swageType = fittingSubtype || '';
+ displayType = `SWAGE ${swageType}`.trim();
+ } else if (fittingType === 'OLET') {
+ const oletSubtype = fittingSubtype || '';
+ let oletDisplayName = '';
+
+ // 백엔드 분류기 결과 우선 사용
+ switch (oletSubtype) {
+ case 'SOCKOLET':
+ oletDisplayName = 'SOCK-O-LET';
+ break;
+ case 'WELDOLET':
+ oletDisplayName = 'WELD-O-LET';
+ break;
+ case 'ELLOLET':
+ oletDisplayName = 'ELL-O-LET';
+ break;
+ case 'THREADOLET':
+ oletDisplayName = 'THREAD-O-LET';
+ break;
+ case 'ELBOLET':
+ oletDisplayName = 'ELB-O-LET';
+ break;
+ case 'NIPOLET':
+ oletDisplayName = 'NIP-O-LET';
+ break;
+ case 'COUPOLET':
+ oletDisplayName = 'COUP-O-LET';
+ break;
+ default:
+ // 백엔드 분류가 없으면 description에서 직접 추출
+ const upperDesc = description.toUpperCase();
+ if (upperDesc.includes('SOCK-O-LET') || upperDesc.includes('SOCKOLET')) {
+ oletDisplayName = 'SOCK-O-LET';
+ } else if (upperDesc.includes('WELD-O-LET') || upperDesc.includes('WELDOLET')) {
+ oletDisplayName = 'WELD-O-LET';
+ } else if (upperDesc.includes('ELL-O-LET') || upperDesc.includes('ELLOLET')) {
+ oletDisplayName = 'ELL-O-LET';
+ } else if (upperDesc.includes('THREAD-O-LET') || upperDesc.includes('THREADOLET')) {
+ oletDisplayName = 'THREAD-O-LET';
+ } else if (upperDesc.includes('ELB-O-LET') || upperDesc.includes('ELBOLET')) {
+ oletDisplayName = 'ELB-O-LET';
+ } else if (upperDesc.includes('NIP-O-LET') || upperDesc.includes('NIPOLET')) {
+ oletDisplayName = 'NIP-O-LET';
+ } else if (upperDesc.includes('COUP-O-LET') || upperDesc.includes('COUPOLET')) {
+ oletDisplayName = 'COUP-O-LET';
+ } else {
+ oletDisplayName = 'OLET';
+ }
+ }
+
+ displayType = oletDisplayName;
+ } else if (!displayType) {
+ displayType = fittingType || 'FITTING';
+ }
+
+ // 압력 등급과 스케줄 추출 (기존 NewMaterialsPage 로직)
+ let pressure = '-';
+ let schedule = '-';
+
+ // 압력 등급 찾기 (3000LB, 6000LB 등) - 소켓웰드 피팅에 특히 중요
+ const pressureMatch = description.match(/(\d+)LB/i);
+ if (pressureMatch) {
+ pressure = `${pressureMatch[1]}LB`;
+ }
+
+ // 소켓웰드 피팅의 경우 압력 등급이 더 중요함
+ if (description.includes('SW') && !pressureMatch) {
+ // SW 피팅인데 압력이 명시되지 않은 경우 기본값 설정
+ if (description.includes('3000') || description.includes('3K')) {
+ pressure = '3000LB';
+ } else if (description.includes('6000') || description.includes('6K')) {
+ pressure = '6000LB';
+ }
+ }
+
+ // 스케줄 표시 (분리 스케줄 지원)
+ if (hasDifferentSchedules && mainSchedule && redSchedule) {
+ schedule = `${mainSchedule}×${redSchedule}`;
+ } else if (mainSchedule) {
+ schedule = mainSchedule;
+ } else {
+ // Description에서 스케줄 추출
+ const scheduleMatch = description.match(/SCH\s*(\d+[A-Z]*)/i);
+ if (scheduleMatch) {
+ schedule = `SCH ${scheduleMatch[1]}`;
+ }
+ }
+
+ return {
+ type: 'FITTING',
+ subtype: displayType,
+ size: material.size_spec || '-',
+ pressure: pressure,
+ schedule: schedule,
+ grade: material.full_material_grade || material.material_grade || '-',
+ quantity: Math.round(material.quantity || 0),
+ unit: '개',
+ isFitting: true
+ };
+ };
+
+ // 정렬 처리
+ const handleSort = (key) => {
+ let direction = 'asc';
+ if (sortConfig.key === key && sortConfig.direction === 'asc') {
+ direction = 'desc';
+ }
+ setSortConfig({ key, direction });
+ };
+
+ // 필터링된 및 정렬된 자재 목록
+ const getFilteredAndSortedMaterials = () => {
+ let filtered = materials.filter(material => {
+ return Object.entries(columnFilters).every(([key, filterValue]) => {
+ if (!filterValue) return true;
+ const info = parseFittingInfo(material);
+ const value = info[key]?.toString().toLowerCase() || '';
+ return value.includes(filterValue.toLowerCase());
+ });
+ });
+
+ if (sortConfig.key) {
+ filtered.sort((a, b) => {
+ const aInfo = parseFittingInfo(a);
+ const bInfo = parseFittingInfo(b);
+ const aValue = aInfo[sortConfig.key] || '';
+ const bValue = bInfo[sortConfig.key] || '';
+
+ if (sortConfig.direction === 'asc') {
+ return aValue > bValue ? 1 : -1;
+ } else {
+ return aValue < bValue ? 1 : -1;
+ }
+ });
+ }
+
+ return filtered;
+ };
+
+ // 전체 선택/해제 (구매신청된 자재 제외)
+ const handleSelectAll = () => {
+ const filteredMaterials = getFilteredAndSortedMaterials();
+ const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
+
+ if (selectedMaterials.size === selectableMaterials.length) {
+ setSelectedMaterials(new Set());
+ } else {
+ setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
+ }
+ };
+
+ // 개별 선택 (구매신청된 자재는 선택 불가)
+ const handleMaterialSelect = (materialId) => {
+ if (purchasedMaterials.has(materialId)) {
+ return; // 구매신청된 자재는 선택 불가
+ }
+
+ const newSelected = new Set(selectedMaterials);
+ if (newSelected.has(materialId)) {
+ newSelected.delete(materialId);
+ } else {
+ newSelected.add(materialId);
+ }
+ setSelectedMaterials(newSelected);
+ };
+
+ // 엑셀 내보내기
+ const handleExportToExcel = async () => {
+ const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
+ if (selectedMaterialsData.length === 0) {
+ alert('내보낼 자재를 선택해주세요.');
+ return;
+ }
+
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
+ const excelFileName = `FITTING_Materials_${timestamp}.xlsx`;
+
+ const dataWithRequirements = selectedMaterialsData.map(material => ({
+ ...material,
+ user_requirement: userRequirements[material.id] || ''
+ }));
+
+ try {
+ await api.post('/files/save-excel', {
+ file_id: fileId,
+ category: 'FITTING',
+ materials: dataWithRequirements,
+ filename: excelFileName,
+ user_id: user?.id
+ });
+
+ exportMaterialsToExcel(dataWithRequirements, excelFileName, {
+ category: 'FITTING',
+ filename: excelFileName,
+ uploadDate: new Date().toLocaleDateString()
+ });
+
+ alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
+ } catch (error) {
+ console.error('엑셀 저장 실패:', error);
+ exportMaterialsToExcel(dataWithRequirements, excelFileName, {
+ category: 'FITTING',
+ filename: excelFileName,
+ uploadDate: new Date().toLocaleDateString()
+ });
+ }
+ };
+
+ const filteredMaterials = getFilteredAndSortedMaterials();
+
+ // 필터 헤더 컴포넌트
+ const FilterableHeader = ({ sortKey, filterKey, children }) => (
+
+
+ handleSort(sortKey)}
+ style={{ cursor: 'pointer', flex: 1 }}
+ >
+ {children}
+ {sortConfig.key === sortKey && (
+
+ {sortConfig.direction === 'asc' ? '↑' : '↓'}
+
+ )}
+
+
+
+ {showFilterDropdown === filterKey && (
+
+ setColumnFilters({
+ ...columnFilters,
+ [filterKey]: e.target.value
+ })}
+ style={{
+ width: '100%',
+ padding: '4px 8px',
+ border: '1px solid #d1d5db',
+ borderRadius: '4px',
+ fontSize: '12px'
+ }}
+ autoFocus
+ />
+
+ )}
+
+ );
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+ Fitting Materials
+
+
+ {filteredMaterials.length} items • {selectedMaterials.size} selected
+
+
+
+
+
+
+
+
+
+
+ {/* 테이블 */}
+
+ {/* 헤더 */}
+
+
+ {
+ const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
+ return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
+ })()}
+ onChange={handleSelectAll}
+ style={{ cursor: 'pointer' }}
+ />
+
+
Type
+
Size
+
Pressure
+
Schedule
+
Material Grade
+
Quantity
+
Unit
+
User Requirement
+
+
+ {/* 데이터 행들 */}
+
+ {filteredMaterials.map((material, index) => {
+ const info = parseFittingInfo(material);
+ const isSelected = selectedMaterials.has(material.id);
+ const isPurchased = purchasedMaterials.has(material.id);
+
+ return (
+
{
+ if (!isSelected && !isPurchased) {
+ e.target.style.background = '#f8fafc';
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (!isSelected && !isPurchased) {
+ e.target.style.background = 'white';
+ }
+ }}
+ >
+
+ handleMaterialSelect(material.id)}
+ disabled={isPurchased}
+ style={{
+ cursor: isPurchased ? 'not-allowed' : 'pointer',
+ opacity: isPurchased ? 0.5 : 1
+ }}
+ />
+
+
+ {info.subtype}
+ {isPurchased && (
+
+ PURCHASED
+
+ )}
+
+
+ {info.size}
+
+
+ {info.pressure}
+
+
+ {info.schedule}
+
+
+ {info.grade}
+
+
+ {info.quantity}
+
+
+ {info.unit}
+
+
+ setUserRequirements({
+ ...userRequirements,
+ [material.id]: e.target.value
+ })}
+ placeholder="Enter requirement..."
+ style={{
+ width: '100%',
+ padding: '6px 8px',
+ border: '1px solid #d1d5db',
+ borderRadius: '4px',
+ fontSize: '12px'
+ }}
+ />
+
+
+ );
+ })}
+
+
+
+ {filteredMaterials.length === 0 && (
+
+
⚙️
+
+ No Fitting Materials Found
+
+
+ {Object.keys(columnFilters).some(key => columnFilters[key])
+ ? 'Try adjusting your filters'
+ : 'No fitting materials available in this BOM'}
+
+
+ )}
+
+ );
+};
+
+export default FittingMaterialsView;
diff --git a/frontend/src/components/bom/materials/FlangeMaterialsView.jsx b/frontend/src/components/bom/materials/FlangeMaterialsView.jsx
new file mode 100644
index 0000000..e9f550f
--- /dev/null
+++ b/frontend/src/components/bom/materials/FlangeMaterialsView.jsx
@@ -0,0 +1,512 @@
+import React, { useState } from 'react';
+import { exportMaterialsToExcel } from '../../../utils/excelExport';
+import api from '../../../api';
+import { FilterableHeader, MaterialTable } from '../shared';
+
+const FlangeMaterialsView = ({
+ materials,
+ selectedMaterials,
+ setSelectedMaterials,
+ userRequirements,
+ setUserRequirements,
+ purchasedMaterials,
+ fileId,
+ user,
+ onNavigate
+}) => {
+ const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
+ const [columnFilters, setColumnFilters] = useState({});
+ const [showFilterDropdown, setShowFilterDropdown] = useState(null);
+
+ // 플랜지 정보 파싱
+ const parseFlangeInfo = (material) => {
+ const description = material.original_description || '';
+ const flangeDetails = material.flange_details || {};
+
+ const flangeTypeMap = {
+ 'WN': 'WELD NECK FLANGE',
+ 'WELD_NECK': 'WELD NECK FLANGE',
+ 'SO': 'SLIP ON FLANGE',
+ 'SLIP_ON': 'SLIP ON FLANGE',
+ 'SW': 'SOCKET WELD FLANGE',
+ 'SOCKET_WELD': 'SOCKET WELD FLANGE',
+ 'BLIND': 'BLIND FLANGE',
+ 'REDUCING': 'REDUCING FLANGE',
+ 'ORIFICE': 'ORIFICE FLANGE',
+ 'SPECTACLE': 'SPECTACLE BLIND',
+ 'PADDLE': 'PADDLE BLIND',
+ 'SPACER': 'SPACER'
+ };
+
+ const facingTypeMap = {
+ 'RF': 'RAISED FACE',
+ 'RAISED_FACE': 'RAISED FACE',
+ 'FF': 'FLAT FACE',
+ 'FLAT_FACE': 'FLAT FACE',
+ 'RTJ': 'RING TYPE JOINT',
+ 'RING_TYPE_JOINT': 'RING TYPE JOINT'
+ };
+
+ const rawFlangeType = flangeDetails.flange_type || '';
+ const rawFacingType = flangeDetails.facing_type || '';
+
+ let displayType = flangeTypeMap[rawFlangeType] || rawFlangeType || '-';
+ let facingType = facingTypeMap[rawFacingType] || rawFacingType || '-';
+
+ // Description에서 추출
+ if (displayType === '-') {
+ const desc = description.toUpperCase();
+ if (desc.includes('ORIFICE')) {
+ displayType = 'ORIFICE FLANGE';
+ } else if (desc.includes('SPECTACLE')) {
+ displayType = 'SPECTACLE BLIND';
+ } else if (desc.includes('PADDLE')) {
+ displayType = 'PADDLE BLIND';
+ } else if (desc.includes('SPACER')) {
+ displayType = 'SPACER';
+ } else if (desc.includes('REDUCING') || desc.includes('RED')) {
+ displayType = 'REDUCING FLANGE';
+ } else if (desc.includes('BLIND')) {
+ displayType = 'BLIND FLANGE';
+ } else if (desc.includes('WN')) {
+ displayType = 'WELD NECK FLANGE';
+ } else if (desc.includes('SO')) {
+ displayType = 'SLIP ON FLANGE';
+ } else if (desc.includes('SW')) {
+ displayType = 'SOCKET WELD FLANGE';
+ } else {
+ displayType = 'FLANGE';
+ }
+ }
+
+ if (facingType === '-') {
+ const desc = description.toUpperCase();
+ if (desc.includes('RF')) {
+ facingType = 'RAISED FACE';
+ } else if (desc.includes('FF')) {
+ facingType = 'FLAT FACE';
+ } else if (desc.includes('RTJ')) {
+ facingType = 'RING TYPE JOINT';
+ }
+ }
+
+ // 원본 설명에서 스케줄 추출
+ let schedule = '-';
+ const upperDesc = description.toUpperCase();
+
+ // SCH 40, SCH 80 등의 패턴 찾기
+ if (upperDesc.includes('SCH')) {
+ const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i);
+ if (schMatch && schMatch[1]) {
+ schedule = `SCH ${schMatch[1]}`;
+ }
+ }
+
+ // 압력 등급 추출
+ let pressure = '-';
+ const pressureMatch = description.match(/(\d+)LB/i);
+ if (pressureMatch) {
+ pressure = `${pressureMatch[1]}LB`;
+ }
+
+ return {
+ type: 'FLANGE',
+ subtype: displayType, // 풀네임 플랜지 타입
+ facing: facingType, // 새로 추가: 끝단처리 정보
+ size: material.size_spec || '-',
+ pressure: flangeDetails.pressure_rating || pressure,
+ schedule: schedule,
+ grade: material.full_material_grade || material.material_grade || '-',
+ quantity: Math.round(material.quantity || 0),
+ unit: '개',
+ isFlange: true // 플랜지 구분용 플래그
+ };
+ };
+
+ // 정렬 처리
+ const handleSort = (key) => {
+ let direction = 'asc';
+ if (sortConfig.key === key && sortConfig.direction === 'asc') {
+ direction = 'desc';
+ }
+ setSortConfig({ key, direction });
+ };
+
+ // 필터링된 및 정렬된 자재 목록
+ const getFilteredAndSortedMaterials = () => {
+ let filtered = materials.filter(material => {
+ return Object.entries(columnFilters).every(([key, filterValue]) => {
+ if (!filterValue) return true;
+ const info = parseFlangeInfo(material);
+ const value = info[key]?.toString().toLowerCase() || '';
+ return value.includes(filterValue.toLowerCase());
+ });
+ });
+
+ if (sortConfig.key) {
+ filtered.sort((a, b) => {
+ const aInfo = parseFlangeInfo(a);
+ const bInfo = parseFlangeInfo(b);
+ const aValue = aInfo[sortConfig.key] || '';
+ const bValue = bInfo[sortConfig.key] || '';
+
+ if (sortConfig.direction === 'asc') {
+ return aValue > bValue ? 1 : -1;
+ } else {
+ return aValue < bValue ? 1 : -1;
+ }
+ });
+ }
+
+ return filtered;
+ };
+
+ // 전체 선택/해제
+ const handleSelectAll = () => {
+ const filteredMaterials = getFilteredAndSortedMaterials();
+ if (selectedMaterials.size === filteredMaterials.length) {
+ setSelectedMaterials(new Set());
+ } else {
+ setSelectedMaterials(new Set(filteredMaterials.map(m => m.id)));
+ }
+ };
+
+ // 개별 선택
+ const handleMaterialSelect = (materialId) => {
+ const newSelected = new Set(selectedMaterials);
+ if (newSelected.has(materialId)) {
+ newSelected.delete(materialId);
+ } else {
+ newSelected.add(materialId);
+ }
+ setSelectedMaterials(newSelected);
+ };
+
+ // 엑셀 내보내기
+ const handleExportToExcel = async () => {
+ const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
+ if (selectedMaterialsData.length === 0) {
+ alert('내보낼 자재를 선택해주세요.');
+ return;
+ }
+
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
+ const excelFileName = `FLANGE_Materials_${timestamp}.xlsx`;
+
+ const dataWithRequirements = selectedMaterialsData.map(material => ({
+ ...material,
+ user_requirement: userRequirements[material.id] || ''
+ }));
+
+ try {
+ await api.post('/files/save-excel', {
+ file_id: fileId,
+ category: 'FLANGE',
+ materials: dataWithRequirements,
+ filename: excelFileName,
+ user_id: user?.id
+ });
+
+ exportMaterialsToExcel(dataWithRequirements, excelFileName, {
+ category: 'FLANGE',
+ filename: excelFileName,
+ uploadDate: new Date().toLocaleDateString()
+ });
+
+ alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
+ } catch (error) {
+ console.error('엑셀 저장 실패:', error);
+ exportMaterialsToExcel(dataWithRequirements, excelFileName, {
+ category: 'FLANGE',
+ filename: excelFileName,
+ uploadDate: new Date().toLocaleDateString()
+ });
+ }
+ };
+
+ const filteredMaterials = getFilteredAndSortedMaterials();
+
+ // 필터 헤더 컴포넌트
+ const FilterableHeader = ({ sortKey, filterKey, children }) => (
+
+
+ handleSort(sortKey)}
+ style={{ cursor: 'pointer', flex: 1 }}
+ >
+ {children}
+ {sortConfig.key === sortKey && (
+
+ {sortConfig.direction === 'asc' ? '↑' : '↓'}
+
+ )}
+
+
+
+ {showFilterDropdown === filterKey && (
+
+ setColumnFilters({
+ ...columnFilters,
+ [filterKey]: e.target.value
+ })}
+ style={{
+ width: '100%',
+ padding: '4px 8px',
+ border: '1px solid #d1d5db',
+ borderRadius: '4px',
+ fontSize: '12px'
+ }}
+ autoFocus
+ />
+
+ )}
+
+ );
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+ Flange Materials
+
+
+ {filteredMaterials.length} items • {selectedMaterials.size} selected
+
+
+
+
+
+
+
+
+
+
+ {/* 테이블 */}
+
+ {/* 헤더 */}
+
+
+ 0}
+ onChange={handleSelectAll}
+ style={{ cursor: 'pointer' }}
+ />
+
+
Type
+
Facing
+
Size
+
Pressure
+
Schedule
+
Material Grade
+
Quantity
+
Unit
+
User Requirement
+
+
+ {/* 데이터 행들 */}
+
+ {filteredMaterials.map((material, index) => {
+ const info = parseFlangeInfo(material);
+ const isSelected = selectedMaterials.has(material.id);
+ const isPurchased = purchasedMaterials.has(material.id);
+
+ return (
+
{
+ if (!isSelected && !isPurchased) {
+ e.target.style.background = '#f8fafc';
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (!isSelected && !isPurchased) {
+ e.target.style.background = 'white';
+ }
+ }}
+ >
+
+ handleMaterialSelect(material.id)}
+ style={{ cursor: 'pointer' }}
+ />
+
+
+ FLANGE
+ {isPurchased && (
+
+ PURCHASED
+
+ )}
+
+
+ {info.subtype}
+
+
+ {info.facing}
+
+
+ {info.size}
+
+
+ {info.pressure}
+
+
+ {info.schedule}
+
+
+ {info.grade}
+
+
+ {info.quantity}
+
+
+ {info.unit}
+
+
+ setUserRequirements({
+ ...userRequirements,
+ [material.id]: e.target.value
+ })}
+ placeholder="Enter requirement..."
+ style={{
+ width: '100%',
+ padding: '6px 8px',
+ border: '1px solid #d1d5db',
+ borderRadius: '4px',
+ fontSize: '12px'
+ }}
+ />
+
+
+ );
+ })}
+
+
+
+ {filteredMaterials.length === 0 && (
+
+
🔩
+
+ No Flange Materials Found
+
+
+ {Object.keys(columnFilters).some(key => columnFilters[key])
+ ? 'Try adjusting your filters'
+ : 'No flange materials available in this BOM'}
+
+
+ )}
+
+ );
+};
+
+export default FlangeMaterialsView;
diff --git a/frontend/src/components/bom/materials/GasketMaterialsView.jsx b/frontend/src/components/bom/materials/GasketMaterialsView.jsx
new file mode 100644
index 0000000..13725ec
--- /dev/null
+++ b/frontend/src/components/bom/materials/GasketMaterialsView.jsx
@@ -0,0 +1,396 @@
+import React, { useState } from 'react';
+import { exportMaterialsToExcel } from '../../../utils/excelExport';
+import api from '../../../api';
+import { FilterableHeader } from '../shared';
+
+const GasketMaterialsView = ({
+ materials,
+ selectedMaterials,
+ setSelectedMaterials,
+ userRequirements,
+ setUserRequirements,
+ purchasedMaterials,
+ fileId,
+ user
+}) => {
+ const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
+ const [columnFilters, setColumnFilters] = useState({});
+ const [showFilterDropdown, setShowFilterDropdown] = useState(null);
+
+ const parseGasketInfo = (material) => {
+ const qty = Math.round(material.quantity || 0);
+ const purchaseQty = Math.ceil(qty * 1.05 / 5) * 5; // 5% 여유율 + 5의 배수
+
+ // original_description에서 재질 정보 파싱 (기존 NewMaterialsPage와 동일)
+ const description = material.original_description || '';
+ let materialStructure = '-'; // H/F/I/O 부분
+ let materialDetail = '-'; // SS304/GRAPHITE/CS/CS 부분
+
+ // H/F/I/O와 재질 상세 정보 추출
+ const materialMatch = description.match(/H\/F\/I\/O\s+(.+?)(?:,|$)/);
+ if (materialMatch) {
+ materialStructure = 'H/F/I/O';
+ materialDetail = materialMatch[1].trim();
+ // 두께 정보 제거 (별도 추출)
+ materialDetail = materialDetail.replace(/,?\s*\d+(?:\.\d+)?mm$/, '').trim();
+ }
+
+ // 압력 정보 추출
+ let pressure = '-';
+ const pressureMatch = description.match(/(\d+LB)/);
+ if (pressureMatch) {
+ pressure = pressureMatch[1];
+ }
+
+ // 두께 정보 추출
+ let thickness = '-';
+ const thicknessMatch = description.match(/(\d+(?:\.\d+)?)\s*mm/i);
+ if (thicknessMatch) {
+ thickness = thicknessMatch[1] + 'mm';
+ }
+
+ return {
+ type: 'GASKET',
+ subtype: 'SWG', // 항상 SWG로 표시
+ size: material.size_spec || '-',
+ pressure: pressure,
+ schedule: thickness, // 두께를 schedule 열에 표시
+ materialStructure: materialStructure,
+ materialDetail: materialDetail,
+ thickness: thickness,
+ grade: materialDetail, // 재질 상세를 grade로 표시
+ quantity: purchaseQty,
+ unit: '개',
+ isGasket: true
+ };
+ };
+
+ // 정렬 처리
+ const handleSort = (key) => {
+ let direction = 'asc';
+ if (sortConfig.key === key && sortConfig.direction === 'asc') {
+ direction = 'desc';
+ }
+ setSortConfig({ key, direction });
+ };
+
+ // 필터링된 및 정렬된 자재 목록
+ const getFilteredAndSortedMaterials = () => {
+ let filtered = materials.filter(material => {
+ return Object.entries(columnFilters).every(([key, filterValue]) => {
+ if (!filterValue) return true;
+ const info = parseGasketInfo(material);
+ const value = info[key]?.toString().toLowerCase() || '';
+ return value.includes(filterValue.toLowerCase());
+ });
+ });
+
+ if (sortConfig.key) {
+ filtered.sort((a, b) => {
+ const aInfo = parseGasketInfo(a);
+ const bInfo = parseGasketInfo(b);
+ const aValue = aInfo[sortConfig.key] || '';
+ const bValue = bInfo[sortConfig.key] || '';
+
+ if (sortConfig.direction === 'asc') {
+ return aValue > bValue ? 1 : -1;
+ } else {
+ return aValue < bValue ? 1 : -1;
+ }
+ });
+ }
+
+ return filtered;
+ };
+
+ // 전체 선택/해제 (구매신청된 자재 제외)
+ const handleSelectAll = () => {
+ const filteredMaterials = getFilteredAndSortedMaterials();
+ const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
+
+ if (selectedMaterials.size === selectableMaterials.length) {
+ setSelectedMaterials(new Set());
+ } else {
+ setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
+ }
+ };
+
+ // 개별 선택 (구매신청된 자재는 선택 불가)
+ const handleMaterialSelect = (materialId) => {
+ if (purchasedMaterials.has(materialId)) {
+ return; // 구매신청된 자재는 선택 불가
+ }
+
+ const newSelected = new Set(selectedMaterials);
+ if (newSelected.has(materialId)) {
+ newSelected.delete(materialId);
+ } else {
+ newSelected.add(materialId);
+ }
+ setSelectedMaterials(newSelected);
+ };
+
+ // 엑셀 내보내기
+ const handleExportToExcel = async () => {
+ const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
+ if (selectedMaterialsData.length === 0) {
+ alert('내보낼 자재를 선택해주세요.');
+ return;
+ }
+
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
+ const excelFileName = `GASKET_Materials_${timestamp}.xlsx`;
+
+ const dataWithRequirements = selectedMaterialsData.map(material => ({
+ ...material,
+ user_requirement: userRequirements[material.id] || ''
+ }));
+
+ try {
+ await api.post('/files/save-excel', {
+ file_id: fileId,
+ category: 'GASKET',
+ materials: dataWithRequirements,
+ filename: excelFileName,
+ user_id: user?.id
+ });
+
+ exportMaterialsToExcel(dataWithRequirements, excelFileName, {
+ category: 'GASKET',
+ filename: excelFileName,
+ uploadDate: new Date().toLocaleDateString()
+ });
+
+ alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
+ } catch (error) {
+ console.error('엑셀 저장 실패:', error);
+ exportMaterialsToExcel(dataWithRequirements, excelFileName, {
+ category: 'GASKET',
+ filename: excelFileName,
+ uploadDate: new Date().toLocaleDateString()
+ });
+ }
+ };
+
+ const filteredMaterials = getFilteredAndSortedMaterials();
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+ Gasket Materials
+
+
+ {filteredMaterials.length} items • {selectedMaterials.size} selected
+
+
+
+
+
+
+
+
+
+
+ {/* 테이블 */}
+
+ {/* 헤더 */}
+
+
+ {
+ const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
+ return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
+ })()}
+ onChange={handleSelectAll}
+ style={{ cursor: 'pointer' }}
+ />
+
+
Type
+
Size
+
Pressure
+
Thickness
+
Material Grade
+
Quantity
+
Unit
+
User Requirement
+
+
+ {/* 데이터 행들 */}
+
+ {filteredMaterials.map((material, index) => {
+ const info = parseGasketInfo(material);
+ const isSelected = selectedMaterials.has(material.id);
+ const isPurchased = purchasedMaterials.has(material.id);
+
+ return (
+
{
+ if (!isSelected && !isPurchased) {
+ e.target.style.background = '#f8fafc';
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (!isSelected && !isPurchased) {
+ e.target.style.background = 'white';
+ }
+ }}
+ >
+
+ handleMaterialSelect(material.id)}
+ disabled={isPurchased}
+ style={{
+ cursor: isPurchased ? 'not-allowed' : 'pointer',
+ opacity: isPurchased ? 0.5 : 1
+ }}
+ />
+
+
+ {info.subtype}
+ {isPurchased && (
+
+ PURCHASED
+
+ )}
+
+
+ {info.size}
+
+
+ {info.pressure}
+
+
+ {info.schedule}
+
+
+ {info.grade}
+
+
+ {info.quantity}
+
+
+ {info.unit}
+
+
+ setUserRequirements({
+ ...userRequirements,
+ [material.id]: e.target.value
+ })}
+ placeholder="Enter requirement..."
+ style={{
+ width: '100%',
+ padding: '6px 8px',
+ border: '1px solid #d1d5db',
+ borderRadius: '4px',
+ fontSize: '12px'
+ }}
+ />
+
+
+ );
+ })}
+
+
+
+ {filteredMaterials.length === 0 && (
+
+
⭕
+
+ No Gasket Materials Found
+
+
+ {Object.keys(columnFilters).some(key => columnFilters[key])
+ ? 'Try adjusting your filters'
+ : 'No gasket materials available in this BOM'}
+
+
+ )}
+
+ );
+};
+
+export default GasketMaterialsView;
diff --git a/frontend/src/components/bom/materials/PipeMaterialsView.jsx b/frontend/src/components/bom/materials/PipeMaterialsView.jsx
new file mode 100644
index 0000000..742524a
--- /dev/null
+++ b/frontend/src/components/bom/materials/PipeMaterialsView.jsx
@@ -0,0 +1,525 @@
+import React, { useState } from 'react';
+import { exportMaterialsToExcel } from '../../../utils/excelExport';
+import api from '../../../api';
+import { FilterableHeader, MaterialTable } from '../shared';
+
+const PipeMaterialsView = ({
+ materials,
+ selectedMaterials,
+ setSelectedMaterials,
+ userRequirements,
+ setUserRequirements,
+ purchasedMaterials,
+ fileId,
+ user,
+ onNavigate
+}) => {
+ const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
+ const [columnFilters, setColumnFilters] = useState({});
+ const [showFilterDropdown, setShowFilterDropdown] = useState(null);
+
+ // 파이프 구매 수량 계산 (기존 로직 복원)
+ const calculatePipePurchase = (material) => {
+ const pipeDetails = material.pipe_details || {};
+ const totalLength = pipeDetails.length || material.length || 0;
+ const standardLength = 6; // 표준 6M
+
+ const purchaseCount = Math.ceil(totalLength / standardLength);
+ const totalPurchaseLength = purchaseCount * standardLength;
+ const wasteLength = totalPurchaseLength - totalLength;
+ const wastePercentage = totalLength > 0 ? (wasteLength / totalLength * 100) : 0;
+
+ return {
+ totalLength,
+ standardLength,
+ purchaseCount,
+ totalPurchaseLength,
+ wasteLength,
+ wastePercentage
+ };
+ };
+
+ // 파이프 정보 파싱 (기존 상세 로직 복원)
+ const parsePipeInfo = (material) => {
+ const calc = calculatePipePurchase(material);
+ const pipeDetails = material.pipe_details || {};
+
+ return {
+ type: 'PIPE',
+ subtype: pipeDetails.manufacturing_method || 'SMLS',
+ size: material.size_spec || '-',
+ schedule: pipeDetails.schedule || material.schedule || '-',
+ grade: material.full_material_grade || material.material_grade || '-',
+ length: calc.totalLength,
+ quantity: calc.purchaseCount,
+ unit: '본',
+ details: calc,
+ isPipe: true
+ };
+ };
+
+ // 정렬 처리
+ const handleSort = (key) => {
+ let direction = 'asc';
+ if (sortConfig.key === key && sortConfig.direction === 'asc') {
+ direction = 'desc';
+ }
+ setSortConfig({ key, direction });
+ };
+
+ // 필터링된 및 정렬된 자재 목록
+ const getFilteredAndSortedMaterials = () => {
+ let filtered = materials.filter(material => {
+ return Object.entries(columnFilters).every(([key, filterValue]) => {
+ if (!filterValue) return true;
+ const info = parsePipeInfo(material);
+ const value = info[key]?.toString().toLowerCase() || '';
+ return value.includes(filterValue.toLowerCase());
+ });
+ });
+
+ if (sortConfig.key) {
+ filtered.sort((a, b) => {
+ const aInfo = parsePipeInfo(a);
+ const bInfo = parsePipeInfo(b);
+ const aValue = aInfo[sortConfig.key] || '';
+ const bValue = bInfo[sortConfig.key] || '';
+
+ if (sortConfig.direction === 'asc') {
+ return aValue > bValue ? 1 : -1;
+ } else {
+ return aValue < bValue ? 1 : -1;
+ }
+ });
+ }
+
+ return filtered;
+ };
+
+ // 전체 선택/해제
+ const handleSelectAll = () => {
+ const filteredMaterials = getFilteredAndSortedMaterials();
+ if (selectedMaterials.size === filteredMaterials.length) {
+ setSelectedMaterials(new Set());
+ } else {
+ setSelectedMaterials(new Set(filteredMaterials.map(m => m.id)));
+ }
+ };
+
+ // 개별 선택
+ const handleMaterialSelect = (materialId) => {
+ const newSelected = new Set(selectedMaterials);
+ if (newSelected.has(materialId)) {
+ newSelected.delete(materialId);
+ } else {
+ newSelected.add(materialId);
+ }
+ setSelectedMaterials(newSelected);
+ };
+
+ // 엑셀 내보내기
+ const handleExportToExcel = async () => {
+ const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
+ if (selectedMaterialsData.length === 0) {
+ alert('내보낼 자재를 선택해주세요.');
+ return;
+ }
+
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
+ const excelFileName = `PIPE_Materials_${timestamp}.xlsx`;
+
+ // 사용자 요구사항 포함
+ const dataWithRequirements = selectedMaterialsData.map(material => ({
+ ...material,
+ user_requirement: userRequirements[material.id] || ''
+ }));
+
+ try {
+ // 서버에 엑셀 파일 저장 요청
+ await api.post('/files/save-excel', {
+ file_id: fileId,
+ category: 'PIPE',
+ materials: dataWithRequirements,
+ filename: excelFileName,
+ user_id: user?.id
+ });
+
+ // 클라이언트에서 다운로드
+ exportMaterialsToExcel(dataWithRequirements, excelFileName, {
+ category: 'PIPE',
+ filename: excelFileName,
+ uploadDate: new Date().toLocaleDateString()
+ });
+
+ alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
+ } catch (error) {
+ console.error('엑셀 저장 실패:', error);
+ // 실패해도 다운로드는 진행
+ exportMaterialsToExcel(dataWithRequirements, excelFileName, {
+ category: 'PIPE',
+ filename: excelFileName,
+ uploadDate: new Date().toLocaleDateString()
+ });
+ }
+ };
+
+ const filteredMaterials = getFilteredAndSortedMaterials();
+
+ // 필터 헤더 컴포넌트
+ const FilterableHeader = ({ sortKey, filterKey, children }) => (
+
+
+ handleSort(sortKey)}
+ style={{ cursor: 'pointer', flex: 1 }}
+ >
+ {children}
+ {sortConfig.key === sortKey && (
+
+ {sortConfig.direction === 'asc' ? '↑' : '↓'}
+
+ )}
+
+
+
+ {showFilterDropdown === filterKey && (
+
+ setColumnFilters({
+ ...columnFilters,
+ [filterKey]: e.target.value
+ })}
+ style={{
+ width: '100%',
+ padding: '4px 8px',
+ border: '1px solid #d1d5db',
+ borderRadius: '4px',
+ fontSize: '12px'
+ }}
+ autoFocus
+ />
+
+ )}
+
+ );
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+ Pipe Materials
+
+
+ {filteredMaterials.length} items • {selectedMaterials.size} selected
+
+
+
+
+
+
+
+
+
+
+ {/* 테이블 */}
+
+ {/* 헤더 */}
+
+
+ 0}
+ onChange={handleSelectAll}
+ style={{ cursor: 'pointer' }}
+ />
+
+
+ Type
+
+
+ Subtype
+
+
+ Size
+
+
+ Schedule
+
+
+ Material Grade
+
+
+ Length (M)
+
+
+ Quantity
+
+
Unit
+
User Requirement
+
+
+ {/* 데이터 행들 */}
+
+ {filteredMaterials.map((material, index) => {
+ const info = parsePipeInfo(material);
+ const isSelected = selectedMaterials.has(material.id);
+ const isPurchased = purchasedMaterials.has(material.id);
+
+ return (
+
{
+ if (!isSelected && !isPurchased) {
+ e.target.style.background = '#f8fafc';
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (!isSelected && !isPurchased) {
+ e.target.style.background = 'white';
+ }
+ }}
+ >
+
+ handleMaterialSelect(material.id)}
+ style={{ cursor: 'pointer' }}
+ />
+
+
+ PIPE
+ {isPurchased && (
+
+ PURCHASED
+
+ )}
+
+
+ {info.subtype}
+
+
+ {info.size}
+
+
+ {info.schedule}
+
+
+ {info.grade}
+
+
+ {info.length.toFixed(2)}
+
+
+ {info.quantity}
+
+
+ {info.unit}
+
+
+ setUserRequirements({
+ ...userRequirements,
+ [material.id]: e.target.value
+ })}
+ placeholder="Enter requirement..."
+ style={{
+ width: '100%',
+ padding: '6px 8px',
+ border: '1px solid #d1d5db',
+ borderRadius: '4px',
+ fontSize: '12px'
+ }}
+ />
+
+
+ );
+ })}
+
+
+
+ {filteredMaterials.length === 0 && (
+
+
🔧
+
+ No Pipe Materials Found
+
+
+ {Object.keys(columnFilters).some(key => columnFilters[key])
+ ? 'Try adjusting your filters'
+ : 'No pipe materials available in this BOM'}
+
+
+ )}
+
+ );
+};
+
+export default PipeMaterialsView;
diff --git a/frontend/src/components/bom/materials/SupportMaterialsView.jsx b/frontend/src/components/bom/materials/SupportMaterialsView.jsx
new file mode 100644
index 0000000..7e4fd51
--- /dev/null
+++ b/frontend/src/components/bom/materials/SupportMaterialsView.jsx
@@ -0,0 +1,377 @@
+import React, { useState } from 'react';
+import { exportMaterialsToExcel } from '../../../utils/excelExport';
+import api from '../../../api';
+import { FilterableHeader } from '../shared';
+
+const SupportMaterialsView = ({
+ materials,
+ selectedMaterials,
+ setSelectedMaterials,
+ userRequirements,
+ setUserRequirements,
+ purchasedMaterials,
+ fileId,
+ user
+}) => {
+ const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
+ const [columnFilters, setColumnFilters] = useState({});
+ const [showFilterDropdown, setShowFilterDropdown] = useState(null);
+
+ const parseSupportInfo = (material) => {
+ const desc = material.original_description || '';
+ const isUrethaneBlock = desc.includes('URETHANE') || desc.includes('BLOCK SHOE') || desc.includes('우레탄');
+ const isClamp = desc.includes('CLAMP') || desc.includes('클램프');
+
+ let subtypeText = '';
+ if (isUrethaneBlock) {
+ subtypeText = '우레탄블럭슈';
+ } else if (isClamp) {
+ subtypeText = '클램프';
+ } else {
+ subtypeText = '유볼트';
+ }
+
+ return {
+ type: 'SUPPORT',
+ subtype: subtypeText,
+ size: material.main_nom || material.size_inch || material.size_spec || '-',
+ pressure: '-', // 서포트는 압력 등급 없음
+ schedule: '-', // 서포트는 스케줄 없음
+ description: material.original_description || '-',
+ grade: material.full_material_grade || material.material_grade || '-',
+ additionalReq: '-',
+ quantity: Math.round(material.quantity || 0),
+ unit: '개',
+ isSupport: true
+ };
+ };
+
+ // 정렬 처리
+ const handleSort = (key) => {
+ let direction = 'asc';
+ if (sortConfig.key === key && sortConfig.direction === 'asc') {
+ direction = 'desc';
+ }
+ setSortConfig({ key, direction });
+ };
+
+ // 필터링된 및 정렬된 자재 목록
+ const getFilteredAndSortedMaterials = () => {
+ let filtered = materials.filter(material => {
+ return Object.entries(columnFilters).every(([key, filterValue]) => {
+ if (!filterValue) return true;
+ const info = parseSupportInfo(material);
+ const value = info[key]?.toString().toLowerCase() || '';
+ return value.includes(filterValue.toLowerCase());
+ });
+ });
+
+ if (sortConfig.key) {
+ filtered.sort((a, b) => {
+ const aInfo = parseSupportInfo(a);
+ const bInfo = parseSupportInfo(b);
+ const aValue = aInfo[sortConfig.key] || '';
+ const bValue = bInfo[sortConfig.key] || '';
+
+ if (sortConfig.direction === 'asc') {
+ return aValue > bValue ? 1 : -1;
+ } else {
+ return aValue < bValue ? 1 : -1;
+ }
+ });
+ }
+
+ return filtered;
+ };
+
+ // 전체 선택/해제 (구매신청된 자재 제외)
+ const handleSelectAll = () => {
+ const filteredMaterials = getFilteredAndSortedMaterials();
+ const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
+
+ if (selectedMaterials.size === selectableMaterials.length) {
+ setSelectedMaterials(new Set());
+ } else {
+ setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
+ }
+ };
+
+ // 개별 선택 (구매신청된 자재는 선택 불가)
+ const handleMaterialSelect = (materialId) => {
+ if (purchasedMaterials.has(materialId)) {
+ return; // 구매신청된 자재는 선택 불가
+ }
+
+ const newSelected = new Set(selectedMaterials);
+ if (newSelected.has(materialId)) {
+ newSelected.delete(materialId);
+ } else {
+ newSelected.add(materialId);
+ }
+ setSelectedMaterials(newSelected);
+ };
+
+ // 엑셀 내보내기
+ const handleExportToExcel = async () => {
+ const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
+ if (selectedMaterialsData.length === 0) {
+ alert('내보낼 자재를 선택해주세요.');
+ return;
+ }
+
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
+ const excelFileName = `SUPPORT_Materials_${timestamp}.xlsx`;
+
+ const dataWithRequirements = selectedMaterialsData.map(material => ({
+ ...material,
+ user_requirement: userRequirements[material.id] || ''
+ }));
+
+ try {
+ await api.post('/files/save-excel', {
+ file_id: fileId,
+ category: 'SUPPORT',
+ materials: dataWithRequirements,
+ filename: excelFileName,
+ user_id: user?.id
+ });
+
+ exportMaterialsToExcel(dataWithRequirements, excelFileName, {
+ category: 'SUPPORT',
+ filename: excelFileName,
+ uploadDate: new Date().toLocaleDateString()
+ });
+
+ alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
+ } catch (error) {
+ console.error('엑셀 저장 실패:', error);
+ exportMaterialsToExcel(dataWithRequirements, excelFileName, {
+ category: 'SUPPORT',
+ filename: excelFileName,
+ uploadDate: new Date().toLocaleDateString()
+ });
+ }
+ };
+
+ const filteredMaterials = getFilteredAndSortedMaterials();
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+ Support Materials
+
+
+ {filteredMaterials.length} items • {selectedMaterials.size} selected
+
+
+
+
+
+
+
+
+
+
+ {/* 테이블 */}
+
+ {/* 헤더 */}
+
+
+ {
+ const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
+ return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
+ })()}
+ onChange={handleSelectAll}
+ style={{ cursor: 'pointer' }}
+ />
+
+
Type
+
Size
+
Pressure
+
Schedule
+
Material Grade
+
Quantity
+
Unit
+
User Requirement
+
+
+ {/* 데이터 행들 */}
+
+ {filteredMaterials.map((material, index) => {
+ const info = parseSupportInfo(material);
+ const isSelected = selectedMaterials.has(material.id);
+ const isPurchased = purchasedMaterials.has(material.id);
+
+ return (
+
{
+ if (!isSelected && !isPurchased) {
+ e.target.style.background = '#f8fafc';
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (!isSelected && !isPurchased) {
+ e.target.style.background = 'white';
+ }
+ }}
+ >
+
+ handleMaterialSelect(material.id)}
+ disabled={isPurchased}
+ style={{
+ cursor: isPurchased ? 'not-allowed' : 'pointer',
+ opacity: isPurchased ? 0.5 : 1
+ }}
+ />
+
+
+ {info.subtype}
+ {isPurchased && (
+
+ PURCHASED
+
+ )}
+
+
+ {info.size}
+
+
+ {info.pressure}
+
+
+ {info.schedule}
+
+
+ {info.grade}
+
+
+ {info.quantity}
+
+
+ {info.unit}
+
+
+ setUserRequirements({
+ ...userRequirements,
+ [material.id]: e.target.value
+ })}
+ placeholder="Enter requirement..."
+ style={{
+ width: '100%',
+ padding: '6px 8px',
+ border: '1px solid #d1d5db',
+ borderRadius: '4px',
+ fontSize: '12px'
+ }}
+ />
+
+
+ );
+ })}
+
+
+
+ {filteredMaterials.length === 0 && (
+
+
🏗️
+
+ No Support Materials Found
+
+
+ {Object.keys(columnFilters).some(key => columnFilters[key])
+ ? 'Try adjusting your filters'
+ : 'No support materials available in this BOM'}
+
+
+ )}
+
+ );
+};
+
+export default SupportMaterialsView;
diff --git a/frontend/src/components/bom/materials/ValveMaterialsView.jsx b/frontend/src/components/bom/materials/ValveMaterialsView.jsx
new file mode 100644
index 0000000..f9a0ee8
--- /dev/null
+++ b/frontend/src/components/bom/materials/ValveMaterialsView.jsx
@@ -0,0 +1,403 @@
+import React, { useState } from 'react';
+import { exportMaterialsToExcel } from '../../../utils/excelExport';
+import api from '../../../api';
+import { FilterableHeader } from '../shared';
+
+const ValveMaterialsView = ({
+ materials,
+ selectedMaterials,
+ setSelectedMaterials,
+ userRequirements,
+ setUserRequirements,
+ purchasedMaterials,
+ fileId,
+ user
+}) => {
+ const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
+ const [columnFilters, setColumnFilters] = useState({});
+ const [showFilterDropdown, setShowFilterDropdown] = useState(null);
+
+ const parseValveInfo = (material) => {
+ const valveDetails = material.valve_details || {};
+ const description = material.original_description || '';
+
+ // 밸브 타입 파싱 (GATE, BALL, CHECK, GLOBE 등) - 기존 NewMaterialsPage와 동일
+ let valveType = valveDetails.valve_type || '';
+ if (!valveType && description) {
+ if (description.includes('GATE')) valveType = 'GATE';
+ else if (description.includes('BALL')) valveType = 'BALL';
+ else if (description.includes('CHECK')) valveType = 'CHECK';
+ else if (description.includes('GLOBE')) valveType = 'GLOBE';
+ else if (description.includes('BUTTERFLY')) valveType = 'BUTTERFLY';
+ else if (description.includes('NEEDLE')) valveType = 'NEEDLE';
+ else if (description.includes('RELIEF')) valveType = 'RELIEF';
+ }
+
+ // 연결 방식 파싱 (FLG, SW, THRD 등) - 기존 NewMaterialsPage와 동일
+ let connectionType = '';
+ if (description.includes('FLG')) {
+ connectionType = 'FLG';
+ } else if (description.includes('SW X THRD')) {
+ connectionType = 'SW×THRD';
+ } else if (description.includes('SW')) {
+ connectionType = 'SW';
+ } else if (description.includes('THRD')) {
+ connectionType = 'THRD';
+ } else if (description.includes('BW')) {
+ connectionType = 'BW';
+ }
+
+ // 압력 등급 파싱
+ let pressure = '-';
+ const pressureMatch = description.match(/(\d+)LB/i);
+ if (pressureMatch) {
+ pressure = `${pressureMatch[1]}LB`;
+ }
+
+ // 스케줄은 밸브에는 일반적으로 없음 (기본값)
+ let schedule = '-';
+
+ return {
+ type: 'VALVE',
+ subtype: `${valveType} ${connectionType}`.trim() || 'VALVE', // 타입과 연결방식 결합
+ valveType: valveType,
+ connectionType: connectionType,
+ size: material.size_spec || '-',
+ pressure: pressure,
+ schedule: schedule,
+ grade: material.material_grade || '-',
+ quantity: Math.round(material.quantity || 0),
+ unit: '개',
+ isValve: true
+ };
+ };
+
+ // 정렬 처리
+ const handleSort = (key) => {
+ let direction = 'asc';
+ if (sortConfig.key === key && sortConfig.direction === 'asc') {
+ direction = 'desc';
+ }
+ setSortConfig({ key, direction });
+ };
+
+ // 필터링된 및 정렬된 자재 목록
+ const getFilteredAndSortedMaterials = () => {
+ let filtered = materials.filter(material => {
+ return Object.entries(columnFilters).every(([key, filterValue]) => {
+ if (!filterValue) return true;
+ const info = parseValveInfo(material);
+ const value = info[key]?.toString().toLowerCase() || '';
+ return value.includes(filterValue.toLowerCase());
+ });
+ });
+
+ if (sortConfig.key) {
+ filtered.sort((a, b) => {
+ const aInfo = parseValveInfo(a);
+ const bInfo = parseValveInfo(b);
+ const aValue = aInfo[sortConfig.key] || '';
+ const bValue = bInfo[sortConfig.key] || '';
+
+ if (sortConfig.direction === 'asc') {
+ return aValue > bValue ? 1 : -1;
+ } else {
+ return aValue < bValue ? 1 : -1;
+ }
+ });
+ }
+
+ return filtered;
+ };
+
+ // 전체 선택/해제 (구매신청된 자재 제외)
+ const handleSelectAll = () => {
+ const filteredMaterials = getFilteredAndSortedMaterials();
+ const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
+
+ if (selectedMaterials.size === selectableMaterials.length) {
+ setSelectedMaterials(new Set());
+ } else {
+ setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
+ }
+ };
+
+ // 개별 선택 (구매신청된 자재는 선택 불가)
+ const handleMaterialSelect = (materialId) => {
+ if (purchasedMaterials.has(materialId)) {
+ return; // 구매신청된 자재는 선택 불가
+ }
+
+ const newSelected = new Set(selectedMaterials);
+ if (newSelected.has(materialId)) {
+ newSelected.delete(materialId);
+ } else {
+ newSelected.add(materialId);
+ }
+ setSelectedMaterials(newSelected);
+ };
+
+ // 엑셀 내보내기
+ const handleExportToExcel = async () => {
+ const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
+ if (selectedMaterialsData.length === 0) {
+ alert('내보낼 자재를 선택해주세요.');
+ return;
+ }
+
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
+ const excelFileName = `VALVE_Materials_${timestamp}.xlsx`;
+
+ const dataWithRequirements = selectedMaterialsData.map(material => ({
+ ...material,
+ user_requirement: userRequirements[material.id] || ''
+ }));
+
+ try {
+ await api.post('/files/save-excel', {
+ file_id: fileId,
+ category: 'VALVE',
+ materials: dataWithRequirements,
+ filename: excelFileName,
+ user_id: user?.id
+ });
+
+ exportMaterialsToExcel(dataWithRequirements, excelFileName, {
+ category: 'VALVE',
+ filename: excelFileName,
+ uploadDate: new Date().toLocaleDateString()
+ });
+
+ alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
+ } catch (error) {
+ console.error('엑셀 저장 실패:', error);
+ exportMaterialsToExcel(dataWithRequirements, excelFileName, {
+ category: 'VALVE',
+ filename: excelFileName,
+ uploadDate: new Date().toLocaleDateString()
+ });
+ }
+ };
+
+ const filteredMaterials = getFilteredAndSortedMaterials();
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+ Valve Materials
+
+
+ {filteredMaterials.length} items • {selectedMaterials.size} selected
+
+
+
+
+
+
+
+
+
+
+ {/* 테이블 */}
+
+ {/* 헤더 */}
+
+
+ {
+ const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
+ return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
+ })()}
+ onChange={handleSelectAll}
+ style={{ cursor: 'pointer' }}
+ />
+
+
Type
+
Size
+
Pressure
+
Schedule
+
Material Grade
+
Quantity
+
Unit
+
User Requirement
+
+
+ {/* 데이터 행들 */}
+
+ {filteredMaterials.map((material, index) => {
+ const info = parseValveInfo(material);
+ const isSelected = selectedMaterials.has(material.id);
+ const isPurchased = purchasedMaterials.has(material.id);
+
+ return (
+
{
+ if (!isSelected && !isPurchased) {
+ e.target.style.background = '#f8fafc';
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (!isSelected && !isPurchased) {
+ e.target.style.background = 'white';
+ }
+ }}
+ >
+
+ handleMaterialSelect(material.id)}
+ disabled={isPurchased}
+ style={{
+ cursor: isPurchased ? 'not-allowed' : 'pointer',
+ opacity: isPurchased ? 0.5 : 1
+ }}
+ />
+
+
+ {info.subtype}
+ {isPurchased && (
+
+ PURCHASED
+
+ )}
+
+
+ {info.size}
+
+
+ {info.pressure}
+
+
+ {info.schedule}
+
+
+ {info.grade}
+
+
+ {info.quantity}
+
+
+ {info.unit}
+
+
+ setUserRequirements({
+ ...userRequirements,
+ [material.id]: e.target.value
+ })}
+ placeholder="Enter requirement..."
+ style={{
+ width: '100%',
+ padding: '6px 8px',
+ border: '1px solid #d1d5db',
+ borderRadius: '4px',
+ fontSize: '12px'
+ }}
+ />
+
+
+ );
+ })}
+
+
+
+ {filteredMaterials.length === 0 && (
+
+
🚰
+
+ No Valve Materials Found
+
+
+ {Object.keys(columnFilters).some(key => columnFilters[key])
+ ? 'Try adjusting your filters'
+ : 'No valve materials available in this BOM'}
+
+
+ )}
+
+ );
+};
+
+export default ValveMaterialsView;
diff --git a/frontend/src/components/bom/materials/index.js b/frontend/src/components/bom/materials/index.js
new file mode 100644
index 0000000..bec7cda
--- /dev/null
+++ b/frontend/src/components/bom/materials/index.js
@@ -0,0 +1,8 @@
+// BOM Materials Components
+export { default as PipeMaterialsView } from './PipeMaterialsView';
+export { default as FittingMaterialsView } from './FittingMaterialsView';
+export { default as FlangeMaterialsView } from './FlangeMaterialsView';
+export { default as ValveMaterialsView } from './ValveMaterialsView';
+export { default as GasketMaterialsView } from './GasketMaterialsView';
+export { default as BoltMaterialsView } from './BoltMaterialsView';
+export { default as SupportMaterialsView } from './SupportMaterialsView';
diff --git a/frontend/src/components/bom/shared/FilterableHeader.jsx b/frontend/src/components/bom/shared/FilterableHeader.jsx
new file mode 100644
index 0000000..a0a28dc
--- /dev/null
+++ b/frontend/src/components/bom/shared/FilterableHeader.jsx
@@ -0,0 +1,78 @@
+import React from 'react';
+
+const FilterableHeader = ({
+ sortKey,
+ filterKey,
+ children,
+ sortConfig,
+ onSort,
+ columnFilters,
+ onFilterChange,
+ showFilterDropdown,
+ setShowFilterDropdown
+}) => {
+ return (
+
+
+ onSort(sortKey)}
+ style={{ cursor: 'pointer', flex: 1 }}
+ >
+ {children}
+ {sortConfig.key === sortKey && (
+
+ {sortConfig.direction === 'asc' ? '↑' : '↓'}
+
+ )}
+
+
+
+ {showFilterDropdown === filterKey && (
+
+ onFilterChange({
+ ...columnFilters,
+ [filterKey]: e.target.value
+ })}
+ style={{
+ width: '100%',
+ padding: '4px 8px',
+ border: '1px solid #d1d5db',
+ borderRadius: '4px',
+ fontSize: '12px'
+ }}
+ autoFocus
+ />
+
+ )}
+
+ );
+};
+
+export default FilterableHeader;
diff --git a/frontend/src/components/bom/shared/MaterialTable.jsx b/frontend/src/components/bom/shared/MaterialTable.jsx
new file mode 100644
index 0000000..2a4ccb6
--- /dev/null
+++ b/frontend/src/components/bom/shared/MaterialTable.jsx
@@ -0,0 +1,161 @@
+import React from 'react';
+
+const MaterialTable = ({
+ children,
+ className = '',
+ style = {}
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+const MaterialTableHeader = ({
+ children,
+ gridColumns,
+ className = ''
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+const MaterialTableBody = ({
+ children,
+ maxHeight = '600px',
+ className = ''
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+const MaterialTableRow = ({
+ children,
+ gridColumns,
+ isSelected = false,
+ isPurchased = false,
+ isLast = false,
+ onClick,
+ className = ''
+}) => {
+ return (
+
{
+ if (!isSelected && !isPurchased && !onClick) {
+ e.target.style.background = '#f8fafc';
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (!isSelected && !isPurchased && !onClick) {
+ e.target.style.background = 'white';
+ }
+ }}
+ >
+ {children}
+
+ );
+};
+
+const MaterialTableCell = ({
+ children,
+ align = 'left',
+ fontWeight = 'normal',
+ color = '#1f2937',
+ className = ''
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+const MaterialTableEmpty = ({
+ icon = '📦',
+ title = 'No Materials Found',
+ message = 'No materials available',
+ className = ''
+}) => {
+ return (
+
+
{icon}
+
+ {title}
+
+
+ {message}
+
+
+ );
+};
+
+// 복합 컴포넌트로 export
+MaterialTable.Header = MaterialTableHeader;
+MaterialTable.Body = MaterialTableBody;
+MaterialTable.Row = MaterialTableRow;
+MaterialTable.Cell = MaterialTableCell;
+MaterialTable.Empty = MaterialTableEmpty;
+
+export default MaterialTable;
diff --git a/frontend/src/components/bom/shared/index.js b/frontend/src/components/bom/shared/index.js
new file mode 100644
index 0000000..c78a86c
--- /dev/null
+++ b/frontend/src/components/bom/shared/index.js
@@ -0,0 +1,3 @@
+// BOM Shared Components
+export { default as FilterableHeader } from './FilterableHeader';
+export { default as MaterialTable } from './MaterialTable';
diff --git a/frontend/src/components/common/ErrorBoundary.jsx b/frontend/src/components/common/ErrorBoundary.jsx
new file mode 100644
index 0000000..3164135
--- /dev/null
+++ b/frontend/src/components/common/ErrorBoundary.jsx
@@ -0,0 +1,163 @@
+import React from 'react';
+
+class ErrorBoundary extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = { hasError: false, error: null, errorInfo: null };
+ }
+
+ static getDerivedStateFromError(error) {
+ return { hasError: true };
+ }
+
+ componentDidCatch(error, errorInfo) {
+ this.setState({
+ error: error,
+ errorInfo: errorInfo
+ });
+
+ // 에러 로깅
+ console.error('ErrorBoundary caught an error:', error, errorInfo);
+
+ // 에러 컨텍스트 정보 로깅
+ if (this.props.errorContext) {
+ console.error('Error context:', this.props.errorContext);
+ }
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
+
⚠️
+
+
+ Something went wrong
+
+
+
+ An unexpected error occurred. Please try refreshing the page or contact support if the problem persists.
+
+
+
+
+
+
+
+
+ {/* 개발 환경에서만 에러 상세 정보 표시 */}
+ {process.env.NODE_ENV === 'development' && this.state.error && (
+
+
+ Error Details (Development)
+
+
+ {this.state.error && this.state.error.toString()}
+
+ {this.state.errorInfo.componentStack}
+
+
+ )}
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+export default ErrorBoundary;
\ No newline at end of file
diff --git a/frontend/src/components/common/UserMenu.jsx b/frontend/src/components/common/UserMenu.jsx
new file mode 100644
index 0000000..13773c5
--- /dev/null
+++ b/frontend/src/components/common/UserMenu.jsx
@@ -0,0 +1,219 @@
+import React, { useState } from 'react';
+
+const UserMenu = ({ user, onNavigate, onLogout }) => {
+ const [showUserMenu, setShowUserMenu] = useState(false);
+
+ return (
+
+
+
+ {/* 드롭다운 메뉴 */}
+ {showUserMenu && (
+
+
+
+
+ {user?.name || user?.username}
+
+
+ {user?.email || '이메일 없음'}
+
+
+
+
+
+ {user?.role === 'admin' && (
+ <>
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default UserMenu;
diff --git a/frontend/src/components/common/index.js b/frontend/src/components/common/index.js
new file mode 100644
index 0000000..b9dfc88
--- /dev/null
+++ b/frontend/src/components/common/index.js
@@ -0,0 +1,3 @@
+// Common Components
+export { default as UserMenu } from './UserMenu';
+export { default as ErrorBoundary } from './ErrorBoundary';
diff --git a/frontend/src/pages/BOMManagementPage.css b/frontend/src/pages/BOMManagementPage.css
new file mode 100644
index 0000000..fefc743
--- /dev/null
+++ b/frontend/src/pages/BOMManagementPage.css
@@ -0,0 +1,184 @@
+/* BOM Management Page Styles */
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+.bom-management-page {
+ padding: 40px;
+ background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
+ min-height: 100vh;
+}
+
+.bom-header-card {
+ background: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(10px);
+ border-radius: 20px;
+ padding: 32px;
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ margin-bottom: 40px;
+}
+
+.bom-stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 20px;
+ margin-bottom: 32px;
+}
+
+.bom-stat-card {
+ padding: 20px;
+ border-radius: 12px;
+ text-align: center;
+ transition: transform 0.2s ease;
+}
+
+.bom-stat-card:hover {
+ transform: translateY(-2px);
+}
+
+.bom-stat-number {
+ font-size: 32px;
+ font-weight: 700;
+ margin-bottom: 4px;
+}
+
+.bom-stat-label {
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.bom-category-tabs {
+ background: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(10px);
+ border-radius: 20px;
+ padding: 24px 32px;
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ margin-bottom: 40px;
+}
+
+.bom-category-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 16px;
+}
+
+.bom-category-button {
+ border-radius: 12px;
+ padding: 16px 12px;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 600;
+ transition: all 0.2s ease;
+ text-align: center;
+ border: none;
+ outline: none;
+}
+
+.bom-category-button:hover {
+ transform: translateY(-1px);
+}
+
+.bom-category-icon {
+ font-size: 20px;
+ margin-bottom: 8px;
+}
+
+.bom-category-count {
+ font-size: 12px;
+ opacity: 0.8;
+ font-weight: 500;
+}
+
+.bom-content-card {
+ background: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(10px);
+ border-radius: 20px;
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ overflow: hidden;
+}
+
+.bom-loading {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100vh;
+ background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
+}
+
+.bom-loading-spinner {
+ width: 60px;
+ height: 60px;
+ border: 4px solid #e2e8f0;
+ border-top: 4px solid #3b82f6;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin: 0 auto 20px;
+}
+
+.bom-loading-text {
+ font-size: 18px;
+ color: #64748b;
+ font-weight: 600;
+}
+
+.bom-error {
+ padding: 60px;
+ text-align: center;
+ color: #dc2626;
+}
+
+.bom-error-icon {
+ font-size: 48px;
+ margin-bottom: 16px;
+}
+
+.bom-error-title {
+ font-size: 18px;
+ font-weight: 600;
+ margin-bottom: 8px;
+}
+
+.bom-error-message {
+ font-size: 14px;
+}
+
+/* 반응형 디자인 */
+@media (max-width: 768px) {
+ .bom-management-page {
+ padding: 20px;
+ }
+
+ .bom-header-card {
+ padding: 24px;
+ }
+
+ .bom-stats-grid {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 16px;
+ }
+
+ .bom-category-grid {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+ }
+
+ .bom-category-button {
+ padding: 12px 8px;
+ font-size: 12px;
+ }
+}
+
+@media (max-width: 480px) {
+ .bom-stats-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .bom-category-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/frontend/src/pages/BOMManagementPage.jsx b/frontend/src/pages/BOMManagementPage.jsx
new file mode 100644
index 0000000..49bd179
--- /dev/null
+++ b/frontend/src/pages/BOMManagementPage.jsx
@@ -0,0 +1,451 @@
+import React, { useState, useEffect } from 'react';
+import { fetchMaterials } from '../api';
+import api from '../api';
+import {
+ PipeMaterialsView,
+ FittingMaterialsView,
+ FlangeMaterialsView,
+ ValveMaterialsView,
+ GasketMaterialsView,
+ BoltMaterialsView,
+ SupportMaterialsView
+} from '../components/bom';
+import './BOMManagementPage.css';
+
+const BOMManagementPage = ({
+ onNavigate,
+ selectedProject,
+ fileId,
+ jobNo,
+ bomName,
+ revision,
+ filename,
+ user
+}) => {
+ const [materials, setMaterials] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [selectedCategory, setSelectedCategory] = useState('PIPE');
+ const [selectedMaterials, setSelectedMaterials] = useState(new Set());
+ const [exportHistory, setExportHistory] = useState([]);
+ const [availableRevisions, setAvailableRevisions] = useState([]);
+ const [currentRevision, setCurrentRevision] = useState(revision || 'Rev.0');
+ const [userRequirements, setUserRequirements] = useState({});
+ const [purchasedMaterials, setPurchasedMaterials] = useState(new Set());
+ const [error, setError] = useState(null);
+
+ // 카테고리 정의
+ const categories = [
+ { key: 'PIPE', label: 'Pipes', icon: '🔧', color: '#3b82f6' },
+ { key: 'FITTING', label: 'Fittings', icon: '⚙️', color: '#10b981' },
+ { key: 'FLANGE', label: 'Flanges', icon: '🔩', color: '#f59e0b' },
+ { key: 'VALVE', label: 'Valves', icon: '🚰', color: '#ef4444' },
+ { key: 'GASKET', label: 'Gaskets', icon: '⭕', color: '#8b5cf6' },
+ { key: 'BOLT', label: 'Bolts', icon: '🔩', color: '#6b7280' },
+ { key: 'SUPPORT', label: 'Supports', icon: '🏗️', color: '#f97316' }
+ ];
+
+ // 자료 로드 함수들
+ const loadMaterials = async (id) => {
+ try {
+ setLoading(true);
+ console.log('🔍 자재 데이터 로딩 중...', {
+ file_id: id,
+ selectedProject: selectedProject?.job_no || selectedProject?.official_project_code,
+ jobNo
+ });
+
+ // 구매신청된 자재 먼저 확인
+ const projectJobNo = selectedProject?.job_no || selectedProject?.official_project_code || jobNo;
+ await loadPurchasedMaterials(projectJobNo);
+
+ const response = await fetchMaterials({
+ file_id: parseInt(id),
+ limit: 10000,
+ exclude_requested: false,
+ job_no: projectJobNo
+ });
+
+ if (response.data?.materials) {
+ const materialsData = response.data.materials;
+ console.log(`✅ ${materialsData.length}개 원본 자재 로드 완료`);
+ setMaterials(materialsData);
+ setError(null);
+ } else {
+ console.warn('⚠️ 자재 데이터가 없습니다:', response.data);
+ setMaterials([]);
+ }
+ } catch (error) {
+ console.error('자재 로드 실패:', error);
+ setError('자재 로드에 실패했습니다.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const loadAvailableRevisions = async () => {
+ try {
+ const response = await api.get('/files/', {
+ params: { job_no: jobNo }
+ });
+
+ const allFiles = Array.isArray(response.data) ? response.data : response.data?.files || [];
+ const sameBomFiles = allFiles.filter(file =>
+ (file.bom_name || file.original_filename) === bomName
+ );
+
+ sameBomFiles.sort((a, b) => {
+ const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
+ const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
+ return revB - revA;
+ });
+
+ setAvailableRevisions(sameBomFiles);
+ } catch (error) {
+ console.error('리비전 목록 조회 실패:', error);
+ }
+ };
+
+ const loadPurchasedMaterials = async (jobNo) => {
+ try {
+ // 새로운 API로 구매신청된 자재 ID 목록 조회
+ const response = await api.get('/purchase-request/requested-materials', {
+ params: {
+ job_no: jobNo,
+ file_id: fileId
+ }
+ });
+
+ if (response.data?.requested_material_ids) {
+ const purchasedIds = new Set(response.data.requested_material_ids);
+ setPurchasedMaterials(purchasedIds);
+ console.log(`✅ ${purchasedIds.size}개 구매신청된 자재 ID 로드 완료`);
+ }
+ } catch (error) {
+ console.error('구매신청 자재 조회 실패:', error);
+ }
+ };
+
+ const loadUserRequirements = async (fileId) => {
+ try {
+ const response = await api.get(`/files/${fileId}/user-requirements`);
+ if (response.data?.requirements) {
+ const reqMap = {};
+ response.data.requirements.forEach(req => {
+ reqMap[req.material_id] = req.requirement;
+ });
+ setUserRequirements(reqMap);
+ }
+ } catch (error) {
+ console.error('사용자 요구사항 로드 실패:', error);
+ }
+ };
+
+ // 초기 로드
+ useEffect(() => {
+ if (fileId) {
+ loadMaterials(fileId);
+ loadAvailableRevisions();
+ loadUserRequirements(fileId);
+ }
+ }, [fileId]);
+
+ // 카테고리별 자재 필터링
+ const getCategoryMaterials = (category) => {
+ return materials.filter(material =>
+ material.classified_category === category ||
+ material.category === category
+ );
+ };
+
+ // 카테고리별 컴포넌트 렌더링
+ const renderCategoryView = () => {
+ const categoryMaterials = getCategoryMaterials(selectedCategory);
+ const commonProps = {
+ materials: categoryMaterials,
+ selectedMaterials,
+ setSelectedMaterials,
+ userRequirements,
+ setUserRequirements,
+ purchasedMaterials,
+ fileId,
+ user,
+ onNavigate
+ };
+
+ switch (selectedCategory) {
+ case 'PIPE':
+ return
;
+ case 'FITTING':
+ return
;
+ case 'FLANGE':
+ return
;
+ case 'VALVE':
+ return
;
+ case 'GASKET':
+ return
;
+ case 'BOLT':
+ return
;
+ case 'SUPPORT':
+ return
;
+ default:
+ return
카테고리를 선택해주세요.
;
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+
+ Loading Materials...
+
+
+
+ );
+ }
+
+ return (
+
+ {/* 헤더 섹션 */}
+
+
+
+
+ BOM Materials Management
+
+
+ {bomName} - {currentRevision} | Project: {selectedProject?.job_name || jobNo}
+
+
+
+
+
+ {/* 통계 정보 */}
+
+
+
+ {materials.length}
+
+
+ Total Materials
+
+
+
+
+
+ {getCategoryMaterials(selectedCategory).length}
+
+
+ {selectedCategory} Items
+
+
+
+
+
+ {selectedMaterials.size}
+
+
+ Selected
+
+
+
+
+
+ {purchasedMaterials.size}
+
+
+ Purchased
+
+
+
+
+
+ {/* 카테고리 탭 */}
+
+
+ {categories.map((category) => {
+ const isActive = selectedCategory === category.key;
+ const count = getCategoryMaterials(category.key).length;
+
+ return (
+
+ );
+ })}
+
+
+
+ {/* 카테고리별 컨텐츠 */}
+
+ {error ? (
+
+
⚠️
+
+ Error Loading Materials
+
+
+ {error}
+
+
+ ) : (
+ renderCategoryView()
+ )}
+
+
+ );
+};
+
+export default BOMManagementPage;
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx
index 71bdeeb..58ff764 100644
--- a/frontend/src/pages/DashboardPage.jsx
+++ b/frontend/src/pages/DashboardPage.jsx
@@ -1,286 +1,1066 @@
import React, { useState, useEffect } from 'react';
-const DashboardPage = ({ user }) => {
- const [stats, setStats] = useState({
- totalProjects: 0,
- activeProjects: 0,
- completedProjects: 0,
- totalMaterials: 0,
- pendingQuotes: 0,
- recentActivities: []
+const DashboardPage = ({
+ user,
+ projects,
+ pendingSignupCount,
+ navigateToPage,
+ loadProjects,
+ createProject,
+ updateProjectName,
+ deleteProject,
+ editingProject,
+ setEditingProject,
+ editedProjectName,
+ setEditedProjectName,
+ showCreateProject,
+ setShowCreateProject,
+ newProjectCode,
+ setNewProjectCode,
+ newProjectName,
+ setNewProjectName,
+ newClientName,
+ setNewClientName,
+ inactiveProjects,
+ setInactiveProjects,
+}) => {
+ const [selectedProject, setSelectedProject] = useState(null);
+ const [showProjectDropdown, setShowProjectDropdown] = useState(false);
+
+ // 프로젝트 생성 모달 닫기
+ const handleCloseCreateProject = () => {
+ setShowCreateProject(false);
+ setNewProjectCode('');
+ setNewProjectName('');
+ setNewClientName('');
+ };
+
+ // 프로젝트 선택 처리
+ const handleProjectSelect = (project) => {
+ setSelectedProject(project);
+ setShowProjectDropdown(false);
+ };
+
+ // 프로젝트 비활성화
+ const handleDeactivateProject = (project) => {
+ if (window.confirm(`"${project.job_name || project.job_no}" 프로젝트를 비활성화하시겠습니까?`)) {
+ setInactiveProjects(prev => new Set([...prev, project.job_no]));
+ if (selectedProject?.job_no === project.job_no) {
+ setSelectedProject(null);
+ }
+ setShowProjectDropdown(false);
+ }
+ };
+
+ // 프로젝트 활성화
+ const handleActivateProject = (project) => {
+ setInactiveProjects(prev => {
+ const newSet = new Set(prev);
+ newSet.delete(project.job_no);
+ return newSet;
});
+ };
- useEffect(() => {
- // 실제로는 API에서 데이터를 가져올 예정
- // 현재는 더미 데이터 사용
- setStats({
- totalProjects: 25,
- activeProjects: 8,
- completedProjects: 17,
- totalMaterials: 1250,
- pendingQuotes: 3,
- recentActivities: [
- { id: 1, type: 'project', message: '냉동기 프로젝트 #2024-001 생성됨', time: '2시간 전' },
- { id: 2, type: 'bom', message: 'BOG 시스템 BOM 업데이트됨', time: '4시간 전' },
- { id: 3, type: 'quote', message: '다이아프람 펌프 견적서 승인됨', time: '6시간 전' },
- { id: 4, type: 'material', message: '스테인리스 파이프 재고 부족 알림', time: '1일 전' },
- { id: 5, type: 'shipment', message: '드라이어 시스템 출하 완료', time: '2일 전' }
- ]
- });
- }, []);
+ // 프로젝트 삭제 (드롭다운용)
+ const handleDeleteProjectFromDropdown = (project, e) => {
+ e.stopPropagation();
+ if (window.confirm(`"${project.job_name || project.job_no}" 프로젝트를 완전히 삭제하시겠습니까?`)) {
+ deleteProject(project.job_no);
+ setShowProjectDropdown(false);
+ }
+ };
- const getActivityIcon = (type) => {
- const icons = {
- project: '📋',
- bom: '🔧',
- quote: '💰',
- material: '📦',
- shipment: '🚚'
- };
- return icons[type] || '📌';
- };
+ // 컴포넌트 마운트 시 프로젝트 로드
+ useEffect(() => {
+ loadProjects();
+ }, []);
- const StatCard = ({ title, value, icon, color = '#667eea' }) => (
+ return (
+
+ {/* 대시보드 헤더 */}
+
+
+ Dashboard
+
+
+ TK-MP BOM Management System v2.0 - Project Selection Interface
+
+
+
+ {/* 프로젝트 선택 섹션 */}
+
+
+
+
+ Project Selection
+
+
+ Choose a project to access BOM and purchase management
+
+
+
+
+
+
+
+
+
+
+
+ {/* 프로젝트 드롭다운 */}
+
+
+
+ {/* 드롭다운 메뉴 */}
+ {showProjectDropdown && (
+
+ {projects.length === 0 ? (
+
+ No projects available. Create a new one!
+
+ ) : (
+ projects
+ .filter(project => !inactiveProjects.has(project.job_no))
+ .map((project) => (
+
+
handleProjectSelect(project)}
+ style={{
+ padding: '16px 20px',
+ cursor: 'pointer',
+ flex: 1
+ }}
+ onMouseEnter={(e) => e.target.closest('div').style.background = '#f8fafc'}
+ onMouseLeave={(e) => e.target.closest('div').style.background = 'white'}
+ >
+
+ {project.job_name || project.job_no}
+
+
+ Code: {project.job_no} | Client: {project.client_name || 'N/A'}
+
+
+
+ {/* 프로젝트 관리 버튼들 */}
+
+
+
+
+
+
+ ))
+ )}
+
+ )}
+
+
+
+ {/* 프로젝트가 선택된 경우 - 프로젝트 관련 메뉴 */}
+ {selectedProject && (
{
- e.currentTarget.style.transform = 'translateY(-2px)';
- e.currentTarget.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.15)';
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.transform = 'translateY(0)';
- e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)';
+ background: 'rgba(255, 255, 255, 0.9)',
+ backdropFilter: 'blur(10px)',
+ borderRadius: '20px',
+ padding: '32px',
+ boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
+ border: '1px solid rgba(255, 255, 255, 0.2)',
+ marginBottom: '40px',
+ position: 'relative',
+ zIndex: 1
}}>
-
-
-
- {title}
-
-
- {value}
-
-
-
- {icon}
-
+
+ Project Management
+
+
+
+ {/* BOM 관리 */}
+
navigateToPage('bom', { selectedProject })}
+ style={{
+ background: 'white',
+ borderRadius: '16px',
+ padding: '32px',
+ boxShadow: '0 8px 20px rgba(0, 0, 0, 0.08)',
+ border: '1px solid #e2e8f0',
+ cursor: 'pointer',
+ transition: 'all 0.3s ease',
+ textAlign: 'center'
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.transform = 'translateY(-5px)';
+ e.currentTarget.style.boxShadow = '0 12px 30px rgba(0, 0, 0, 0.15)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.transform = 'translateY(0)';
+ e.currentTarget.style.boxShadow = '0 8px 20px rgba(0, 0, 0, 0.08)';
+ }}
+ >
+
+ BOM
+
+
+ BOM Management
+
+
+ Upload and manage Bill of Materials files. Classify materials and generate reports.
+
-
- );
- return (
+ {/* 구매신청 관리 */}
+
navigateToPage('purchase-request', { selectedProject })}
+ style={{
+ background: 'white',
+ borderRadius: '16px',
+ padding: '32px',
+ boxShadow: '0 8px 20px rgba(0, 0, 0, 0.08)',
+ border: '1px solid #e2e8f0',
+ cursor: 'pointer',
+ transition: 'all 0.3s ease',
+ textAlign: 'center'
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.transform = 'translateY(-5px)';
+ e.currentTarget.style.boxShadow = '0 12px 30px rgba(0, 0, 0, 0.15)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.transform = 'translateY(0)';
+ e.currentTarget.style.boxShadow = '0 8px 20px rgba(0, 0, 0, 0.08)';
+ }}
+ >
+
+ REQ
+
+
+ Purchase Request Management
+
+
+ Manage purchase requests and export materials to Excel for procurement.
+
+
+
+
+ )}
+
+ {/* 관리자 메뉴 (Admin 이상만 표시) */}
+ {user?.role === 'admin' && (
+
+
+ System Administration
+
+
+
+ {/* 사용자 관리 */}
+
navigateToPage('user-management')}
+ style={{
+ background: 'white',
+ borderRadius: '16px',
+ padding: '24px',
+ boxShadow: '0 8px 20px rgba(0, 0, 0, 0.08)',
+ border: '1px solid #e2e8f0',
+ cursor: 'pointer',
+ transition: 'all 0.3s ease',
+ textAlign: 'center'
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.transform = 'translateY(-3px)';
+ e.currentTarget.style.boxShadow = '0 12px 25px rgba(0, 0, 0, 0.12)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.transform = 'translateY(0)';
+ e.currentTarget.style.boxShadow = '0 8px 20px rgba(0, 0, 0, 0.08)';
+ }}
+ >
+
+ USER
+
+
+ User Management
+
+
+ Manage user accounts and permissions
+
+ {pendingSignupCount > 0 && (
+
+ {pendingSignupCount} pending
+
+ )}
+
+
+ {/* 시스템 설정 */}
+
navigateToPage('system-settings')}
+ style={{
+ background: 'white',
+ borderRadius: '16px',
+ padding: '24px',
+ boxShadow: '0 8px 20px rgba(0, 0, 0, 0.08)',
+ border: '1px solid #e2e8f0',
+ cursor: 'pointer',
+ transition: 'all 0.3s ease',
+ textAlign: 'center'
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.transform = 'translateY(-3px)';
+ e.currentTarget.style.boxShadow = '0 12px 25px rgba(0, 0, 0, 0.12)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.transform = 'translateY(0)';
+ e.currentTarget.style.boxShadow = '0 8px 20px rgba(0, 0, 0, 0.08)';
+ }}
+ >
+
+ SYS
+
+
+ System Settings
+
+
+ Configure system preferences and settings
+
+
+
+ {/* 시스템 로그 */}
+
navigateToPage('system-logs')}
+ style={{
+ background: 'white',
+ borderRadius: '16px',
+ padding: '24px',
+ boxShadow: '0 8px 20px rgba(0, 0, 0, 0.08)',
+ border: '1px solid #e2e8f0',
+ cursor: 'pointer',
+ transition: 'all 0.3s ease',
+ textAlign: 'center'
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.transform = 'translateY(-3px)';
+ e.currentTarget.style.boxShadow = '0 12px 25px rgba(0, 0, 0, 0.12)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.transform = 'translateY(0)';
+ e.currentTarget.style.boxShadow = '0 8px 20px rgba(0, 0, 0, 0.08)';
+ }}
+ >
+
+ LOG
+
+
+ System Logs
+
+
+ View system activity and error logs
+
+
+
+ {/* 로그 모니터링 */}
+
navigateToPage('log-monitoring')}
+ style={{
+ background: 'white',
+ borderRadius: '16px',
+ padding: '24px',
+ boxShadow: '0 8px 20px rgba(0, 0, 0, 0.08)',
+ border: '1px solid #e2e8f0',
+ cursor: 'pointer',
+ transition: 'all 0.3s ease',
+ textAlign: 'center'
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.transform = 'translateY(-3px)';
+ e.currentTarget.style.boxShadow = '0 12px 25px rgba(0, 0, 0, 0.12)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.transform = 'translateY(0)';
+ e.currentTarget.style.boxShadow = '0 8px 20px rgba(0, 0, 0, 0.08)';
+ }}
+ >
+
+ MON
+
+
+ Log Monitoring
+
+
+ Real-time system monitoring and alerts
+
+
+
+
+ )}
+
+ {/* 시스템 현황 섹션 */}
+
+
+ System Overview
+
-
- {/* 헤더 */}
-
-
- 안녕하세요, {user?.name}님! 👋
-
-
- 오늘도 TK-MP 시스템과 함께 효율적인 업무를 시작해보세요.
-
-
-
- {/* 통계 카드들 */}
-
-
-
-
-
-
-
-
-
- {/* 최근 활동 */}
-
-
- 📈 최근 활동
-
-
- {stats.recentActivities.map(activity => (
-
-
- {getActivityIcon(activity.type)}
-
-
-
- {activity.message}
-
-
- {activity.time}
-
-
-
- ))}
-
-
-
- {/* 빠른 작업 */}
-
-
- ⚡ 빠른 작업
-
-
- {[
- { title: '새 프로젝트 등록', icon: '➕', color: '#667eea' },
- { title: 'BOM 업로드', icon: '📤', color: '#48bb78' },
- { title: '견적서 작성', icon: '📝', color: '#ed8936' },
- { title: '자재 검색', icon: '🔍', color: '#38b2ac' }
- ].map((action, index) => (
-
- ))}
-
-
-
+ {/* 등록된 프로젝트 */}
+
+
+ {projects.length || 0}
+
Registered Projects
+
+ {/* 선택된 프로젝트 */}
+
+
+ {selectedProject ? '1' : '0'}
+
+
Selected Project
+
+ {/* 현재 권한 */}
+
+
+ {user?.role === 'admin' ? 'Admin' : 'User'}
+
+
Current Role
+
+ {/* 시스템 상태 */}
+
+
+ Active
+
+
System Status
+
- );
+
+
+ {/* 프로젝트 생성 모달 */}
+ {showCreateProject && (
+
+
+
+ Create New Project
+
+
+
+ setNewProjectCode(e.target.value)}
+ style={{
+ width: '100%',
+ padding: '12px',
+ borderRadius: '8px',
+ border: '1px solid #cbd5e1',
+ fontSize: '16px',
+ boxSizing: 'border-box'
+ }}
+ placeholder="e.g., J24-001"
+ />
+
+
+
+ setNewProjectName(e.target.value)}
+ style={{
+ width: '100%',
+ padding: '12px',
+ borderRadius: '8px',
+ border: '1px solid #cbd5e1',
+ fontSize: '16px',
+ boxSizing: 'border-box'
+ }}
+ placeholder="e.g., Ulsan SK Energy Expansion"
+ />
+
+
+
+ setNewClientName(e.target.value)}
+ style={{
+ width: '100%',
+ padding: '12px',
+ borderRadius: '8px',
+ border: '1px solid #cbd5e1',
+ fontSize: '16px',
+ boxSizing: 'border-box'
+ }}
+ placeholder="e.g., Samsung Engineering"
+ />
+
+
+
+
+
+
+
+ )}
+
+ );
};
-export default DashboardPage;
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+export default DashboardPage;
\ No newline at end of file
diff --git a/frontend/src/pages/InactiveProjectsPage.jsx b/frontend/src/pages/InactiveProjectsPage.jsx
new file mode 100644
index 0000000..b67d3b4
--- /dev/null
+++ b/frontend/src/pages/InactiveProjectsPage.jsx
@@ -0,0 +1,398 @@
+import React, { useState } from 'react';
+
+const InactiveProjectsPage = ({
+ onNavigate,
+ user,
+ projects,
+ inactiveProjects,
+ onActivateProject,
+ onDeleteProject
+}) => {
+ const [selectedProjects, setSelectedProjects] = useState(new Set());
+
+ // 비활성 프로젝트 목록 필터링
+ const inactiveProjectList = projects.filter(project =>
+ inactiveProjects.has(project.job_no)
+ );
+
+ // 프로젝트 선택/해제
+ const handleProjectSelect = (projectNo) => {
+ setSelectedProjects(prev => {
+ const newSet = new Set(prev);
+ if (newSet.has(projectNo)) {
+ newSet.delete(projectNo);
+ } else {
+ newSet.add(projectNo);
+ }
+ return newSet;
+ });
+ };
+
+ // 전체 선택/해제
+ const handleSelectAll = () => {
+ if (selectedProjects.size === inactiveProjectList.length) {
+ setSelectedProjects(new Set());
+ } else {
+ setSelectedProjects(new Set(inactiveProjectList.map(p => p.job_no)));
+ }
+ };
+
+ // 선택된 프로젝트들 활성화
+ const handleBulkActivate = () => {
+ if (selectedProjects.size === 0) {
+ alert('활성화할 프로젝트를 선택해주세요.');
+ return;
+ }
+
+ if (window.confirm(`선택된 ${selectedProjects.size}개 프로젝트를 활성화하시겠습니까?`)) {
+ selectedProjects.forEach(projectNo => {
+ const project = projects.find(p => p.job_no === projectNo);
+ if (project) {
+ onActivateProject(project);
+ }
+ });
+ setSelectedProjects(new Set());
+ }
+ };
+
+ // 선택된 프로젝트들 삭제
+ const handleBulkDelete = () => {
+ if (selectedProjects.size === 0) {
+ alert('삭제할 프로젝트를 선택해주세요.');
+ return;
+ }
+
+ if (window.confirm(`선택된 ${selectedProjects.size}개 프로젝트를 완전히 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.`)) {
+ selectedProjects.forEach(projectNo => {
+ onDeleteProject(projectNo);
+ });
+ setSelectedProjects(new Set());
+ }
+ };
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+
+ Inactive Projects Management
+
+
+ Manage deactivated projects - activate or permanently delete
+
+
+
+
+
+ {/* 통계 정보 */}
+
+
+
+ {inactiveProjectList.length}
+
+
+ Inactive Projects
+
+
+
+
+
+ {selectedProjects.size}
+
+
+ Selected
+
+
+
+
+ {/* 일괄 작업 버튼들 */}
+ {inactiveProjectList.length > 0 && (
+
+
+
+
+
+
+
+ )}
+
+
+ {/* 비활성 프로젝트 목록 */}
+
+
+ Inactive Projects List
+
+
+ {inactiveProjectList.length === 0 ? (
+
+
📂
+
+ No Inactive Projects
+
+
+ All projects are currently active
+
+
+ ) : (
+
+ {inactiveProjectList.map((project) => (
+
{
+ e.target.style.borderColor = '#cbd5e1';
+ e.target.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
+ }}
+ onMouseLeave={(e) => {
+ e.target.style.borderColor = '#e2e8f0';
+ e.target.style.boxShadow = '0 2px 8px rgba(0,0,0,0.05)';
+ }}
+ >
+
+
handleProjectSelect(project.job_no)}
+ style={{
+ width: '18px',
+ height: '18px',
+ cursor: 'pointer'
+ }}
+ />
+
+
+
+ {project.job_name || project.job_no}
+
+
+ Code: {project.job_no} | Client: {project.client_name || 'N/A'}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+export default InactiveProjectsPage;
diff --git a/frontend/src/pages/NewMaterialsPage.css b/frontend/src/pages/NewMaterialsPage.css
index 11721e7..b36e157 100644
--- a/frontend/src/pages/NewMaterialsPage.css
+++ b/frontend/src/pages/NewMaterialsPage.css
@@ -58,6 +58,8 @@
border-color: #4299e1;
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.2);
}
+
+.materials-header {
display: flex;
align-items: center;
justify-content: space-between;
diff --git a/frontend/src/pages/NewMaterialsPage.jsx b/frontend/src/pages/NewMaterialsPage.jsx
index 4cc717c..11f54a8 100644
--- a/frontend/src/pages/NewMaterialsPage.jsx
+++ b/frontend/src/pages/NewMaterialsPage.jsx
@@ -13,7 +13,8 @@ const NewMaterialsPage = ({
jobNo,
bomName,
revision,
- filename
+ filename,
+ user
}) => {
const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(true);
@@ -127,15 +128,21 @@ const NewMaterialsPage = ({
const loadMaterials = async (id) => {
try {
setLoading(true);
- console.log('🔍 자재 데이터 로딩 중...', { file_id: id });
+ console.log('🔍 자재 데이터 로딩 중...', {
+ file_id: id,
+ selectedProject: selectedProject?.job_no || selectedProject?.official_project_code,
+ jobNo
+ });
// 구매신청된 자재 먼저 확인
- await loadPurchasedMaterials(jobNo);
+ const projectJobNo = selectedProject?.job_no || selectedProject?.official_project_code || jobNo;
+ await loadPurchasedMaterials(projectJobNo);
const response = await fetchMaterials({
file_id: parseInt(id),
limit: 10000,
- exclude_requested: false // 구매신청된 자재도 포함하여 표시
+ exclude_requested: false, // 구매신청된 자재도 포함하여 표시
+ job_no: projectJobNo // 프로젝트별 필터링 추가
});
if (response.data?.materials) {
diff --git a/frontend/src/pages/PurchaseRequestPage.jsx b/frontend/src/pages/PurchaseRequestPage.jsx
index 11b3fbf..7267af3 100644
--- a/frontend/src/pages/PurchaseRequestPage.jsx
+++ b/frontend/src/pages/PurchaseRequestPage.jsx
@@ -8,6 +8,8 @@ const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) =>
const [selectedRequest, setSelectedRequest] = useState(null);
const [requestMaterials, setRequestMaterials] = useState([]);
const [isLoading, setIsLoading] = useState(false);
+ const [editingTitle, setEditingTitle] = useState(null);
+ const [newTitle, setNewTitle] = useState('');
useEffect(() => {
loadRequests();
@@ -81,6 +83,45 @@ const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) =>
}
};
+ const handleEditTitle = (request) => {
+ setEditingTitle(request.request_id);
+ setNewTitle(request.request_no);
+ };
+
+ const handleSaveTitle = async (requestId) => {
+ try {
+ const response = await api.patch(`/purchase-request/${requestId}/title`, {
+ title: newTitle
+ });
+
+ if (response.data.success) {
+ // 목록에서 해당 요청의 제목 업데이트
+ setRequests(prev => prev.map(req =>
+ req.request_id === requestId
+ ? { ...req, request_no: newTitle }
+ : req
+ ));
+
+ // 선택된 요청도 업데이트
+ if (selectedRequest?.request_id === requestId) {
+ setSelectedRequest(prev => ({ ...prev, request_no: newTitle }));
+ }
+
+ setEditingTitle(null);
+ setNewTitle('');
+ console.log('✅ 구매신청 제목 업데이트 완료');
+ }
+ } catch (error) {
+ console.error('❌ 제목 업데이트 실패:', error);
+ alert('제목 업데이트 실패: ' + error.message);
+ }
+ };
+
+ const handleCancelEdit = () => {
+ setEditingTitle(null);
+ setNewTitle('');
+ };
+
return (
@@ -111,7 +152,82 @@ const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) =>
onClick={() => handleRequestSelect(request)}
>
-
{request.request_no}
+ {editingTitle === request.request_id ? (
+
e.stopPropagation()}>
+ setNewTitle(e.target.value)}
+ onKeyPress={(e) => {
+ if (e.key === 'Enter') {
+ handleSaveTitle(request.request_id);
+ } else if (e.key === 'Escape') {
+ handleCancelEdit();
+ }
+ }}
+ style={{
+ width: '200px',
+ padding: '4px 8px',
+ border: '1px solid #ddd',
+ borderRadius: '4px',
+ fontSize: '14px'
+ }}
+ autoFocus
+ />
+
+
+
+ ) : (
+
+ {request.request_no}
+
+
+ )}
{new Date(request.requested_at).toLocaleDateString()}
@@ -154,6 +270,57 @@ const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) =>
+ {/* 원본 파일 정보 */}
+
+
+ 📄 원본 파일 정보
+
+
+
+ 파일명:
+ {selectedRequest.original_filename || 'N/A'}
+
+
+ 프로젝트:
+ {selectedRequest.job_no} - {selectedRequest.job_name}
+
+
+ 신청일:
+ {new Date(selectedRequest.requested_at).toLocaleString()}
+
+
+ 신청자:
+ {selectedRequest.requested_by}
+
+
+ 자재 수량:
+ {selectedRequest.material_count}개
+
+
+ 카테고리:
+ {selectedRequest.category || '전체'}
+
+
+
+
{/* 업로드 당시 분류된 정보를 그대로 표시 */}
{requestMaterials.length === 0 ? (