feat: SPECIAL/UNCLASSIFIED 카테고리 추가 및 WELD GAP 자동 제외
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
주요 변경사항: - SPECIAL 카테고리 추가: 특수 제작 품목 관리 (Type, Drawing, Detail1-4) - UNCLASSIFIED 카테고리 추가: 미분류 자재 원본 그대로 표시 - UNKNOWN → UNCLASSIFIED 통합: 기존 UNKNOWN 카테고리 제거 - WELD GAP 자동 제외: BOM 업로드 시 WELD GAP 항목 자동 필터링 백엔드: - integrated_classifier.py: UNKNOWN → UNCLASSIFIED 변경, SPECIAL 우선순위 분류 - files.py: parse_dataframe에서 WELD GAP 필터링, UNKNOWN 참조 제거 - exclude_classifier.py: WELD GAP 제외 로직 유지 프론트엔드: - SpecialMaterialsView.jsx: 특수 제작 품목 관리 컴포넌트 - UnclassifiedMaterialsView.jsx: 미분류 자재 관리 컴포넌트 - BOMManagementPage.jsx: 새 카테고리 추가 및 라우팅 - excelExport.js: SPECIAL/UNCLASSIFIED 엑셀 내보내기 지원 - 모든 UNKNOWN 참조를 UNCLASSIFIED로 변경 기능 개선: - 저장 기능: 모든 카테고리에 추가요청사항 저장/편집 기능 - P열 납기일 규칙: 모든 카테고리 엑셀 내보내기 통일 - UI 개선: Detail1-4 컬럼명으로 혼동 방지 - 데이터 정리: 모든 프로젝트 및 BOM 데이터 초기화
This commit is contained in:
@@ -231,6 +231,14 @@ def parse_dataframe(df):
|
|||||||
materials = []
|
materials = []
|
||||||
for index, row in df.iterrows():
|
for index, row in df.iterrows():
|
||||||
description = str(row.get(mapped_columns.get('description', ''), ''))
|
description = str(row.get(mapped_columns.get('description', ''), ''))
|
||||||
|
|
||||||
|
# WELD GAP 항목은 업로드 단계에서 제외 (불필요한 계산용 항목)
|
||||||
|
description_upper = description.upper()
|
||||||
|
if ('WELD GAP' in description_upper or 'WELDING GAP' in description_upper or
|
||||||
|
'웰드갭' in description_upper or '용접갭' in description_upper):
|
||||||
|
print(f"⚠️ WELD GAP 항목 제외: {description}")
|
||||||
|
continue
|
||||||
|
|
||||||
quantity_raw = row.get(mapped_columns.get('quantity', ''), 0)
|
quantity_raw = row.get(mapped_columns.get('quantity', ''), 0)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -547,7 +555,7 @@ async def upload_file(
|
|||||||
materials_to_insert.append({
|
materials_to_insert.append({
|
||||||
"file_id": file_id,
|
"file_id": file_id,
|
||||||
"original_description": material_data["original_description"],
|
"original_description": material_data["original_description"],
|
||||||
"classified_category": previous_item.get("category", "UNKNOWN"),
|
"classified_category": previous_item.get("category", "UNCLASSIFIED"),
|
||||||
"confidence": 1.0, # 확정된 자료이므로 신뢰도 100%
|
"confidence": 1.0, # 확정된 자료이므로 신뢰도 100%
|
||||||
"quantity": material_data["quantity"],
|
"quantity": material_data["quantity"],
|
||||||
"unit": material_data.get("unit", "EA"),
|
"unit": material_data.get("unit", "EA"),
|
||||||
@@ -659,7 +667,7 @@ async def upload_file(
|
|||||||
# 1. 통합 분류기로 자재 타입 결정
|
# 1. 통합 분류기로 자재 타입 결정
|
||||||
integrated_result = classify_material_integrated(description, main_nom or "", red_nom or "", length_value)
|
integrated_result = classify_material_integrated(description, main_nom or "", red_nom or "", length_value)
|
||||||
print(f"[분류] {description}")
|
print(f"[분류] {description}")
|
||||||
print(f"통합 분류 결과: {integrated_result.get('category', 'UNKNOWN')} (신뢰도: {integrated_result.get('confidence', 0)}, Level: {integrated_result.get('classification_level', 'NONE')})")
|
print(f"통합 분류 결과: {integrated_result.get('category', 'UNCLASSIFIED')} (신뢰도: {integrated_result.get('confidence', 0)}, Level: {integrated_result.get('classification_level', 'NONE')})")
|
||||||
|
|
||||||
# 2. 제외 대상 확인
|
# 2. 제외 대상 확인
|
||||||
if should_exclude_material(description):
|
if should_exclude_material(description):
|
||||||
@@ -670,7 +678,7 @@ async def upload_file(
|
|||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# 3. 타입별 상세 분류기 실행
|
# 3. 타입별 상세 분류기 실행
|
||||||
material_type = integrated_result.get('category', 'UNKNOWN')
|
material_type = integrated_result.get('category', 'UNCLASSIFIED')
|
||||||
|
|
||||||
if material_type == "PIPE":
|
if material_type == "PIPE":
|
||||||
from ..services.pipe_classifier import classify_pipe_for_purchase
|
from ..services.pipe_classifier import classify_pipe_for_purchase
|
||||||
@@ -714,9 +722,9 @@ async def upload_file(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# UNKNOWN 처리
|
# UNCLASSIFIED 처리
|
||||||
classification_result = {
|
classification_result = {
|
||||||
"category": "UNKNOWN",
|
"category": "UNCLASSIFIED",
|
||||||
"overall_confidence": integrated_result.get('confidence', 0.0),
|
"overall_confidence": integrated_result.get('confidence', 0.0),
|
||||||
"reason": f"분류 불가: {integrated_result.get('evidence', [])}"
|
"reason": f"분류 불가: {integrated_result.get('evidence', [])}"
|
||||||
}
|
}
|
||||||
@@ -728,7 +736,7 @@ async def upload_file(
|
|||||||
integrated_result.get('confidence', 0.0) + 0.2
|
integrated_result.get('confidence', 0.0) + 0.2
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"최종 분류 결과: {classification_result.get('category', 'UNKNOWN')}")
|
print(f"최종 분류 결과: {classification_result.get('category', 'UNCLASSIFIED')}")
|
||||||
|
|
||||||
# 전체 재질명 추출
|
# 전체 재질명 추출
|
||||||
from ..services.material_grade_extractor import extract_full_material_grade
|
from ..services.material_grade_extractor import extract_full_material_grade
|
||||||
@@ -758,7 +766,7 @@ async def upload_file(
|
|||||||
print(f"첫 번째 자재 저장:")
|
print(f"첫 번째 자재 저장:")
|
||||||
print(f" size_spec: '{material_data['size_spec']}'")
|
print(f" size_spec: '{material_data['size_spec']}'")
|
||||||
print(f" original_description: {material_data['original_description']}")
|
print(f" original_description: {material_data['original_description']}")
|
||||||
print(f" category: {classification_result.get('category', 'UNKNOWN')}")
|
print(f" category: {classification_result.get('category', 'UNCLASSIFIED')}")
|
||||||
print(f" drawing_name: {material_data.get('dwg_name')}")
|
print(f" drawing_name: {material_data.get('dwg_name')}")
|
||||||
print(f" line_no: {material_data.get('line_num')}")
|
print(f" line_no: {material_data.get('line_num')}")
|
||||||
|
|
||||||
@@ -774,7 +782,7 @@ async def upload_file(
|
|||||||
"full_material_grade": full_material_grade,
|
"full_material_grade": full_material_grade,
|
||||||
"line_number": material_data["line_number"],
|
"line_number": material_data["line_number"],
|
||||||
"row_number": material_data["row_number"],
|
"row_number": material_data["row_number"],
|
||||||
"classified_category": classification_result.get("category", "UNKNOWN"),
|
"classified_category": classification_result.get("category", "UNCLASSIFIED"),
|
||||||
"classification_confidence": classification_result.get("overall_confidence", 0.0),
|
"classification_confidence": classification_result.get("overall_confidence", 0.0),
|
||||||
"is_verified": False,
|
"is_verified": False,
|
||||||
"drawing_name": material_data.get("dwg_name"),
|
"drawing_name": material_data.get("dwg_name"),
|
||||||
@@ -889,7 +897,7 @@ async def upload_file(
|
|||||||
material_grade_from_classifier = material_info.get("grade", "")
|
material_grade_from_classifier = material_info.get("grade", "")
|
||||||
|
|
||||||
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
|
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
|
||||||
if material_grade_from_classifier and material_grade_from_classifier != "UNKNOWN":
|
if material_grade_from_classifier and material_grade_from_classifier not in ["UNKNOWN", "UNCLASSIFIED"]:
|
||||||
material_spec = material_grade_from_classifier
|
material_spec = material_grade_from_classifier
|
||||||
|
|
||||||
# materials 테이블의 material_grade도 업데이트
|
# materials 테이블의 material_grade도 업데이트
|
||||||
@@ -958,7 +966,7 @@ async def upload_file(
|
|||||||
material_grade = material_info.get("grade", "")
|
material_grade = material_info.get("grade", "")
|
||||||
|
|
||||||
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
|
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
|
||||||
if material_grade and material_grade != "UNKNOWN":
|
if material_grade and material_grade not in ["UNKNOWN", "UNCLASSIFIED"]:
|
||||||
db.execute(text("""
|
db.execute(text("""
|
||||||
UPDATE materials
|
UPDATE materials
|
||||||
SET material_grade = :new_material_grade
|
SET material_grade = :new_material_grade
|
||||||
@@ -1055,7 +1063,7 @@ async def upload_file(
|
|||||||
material_grade = material_info.get("grade", "")
|
material_grade = material_info.get("grade", "")
|
||||||
|
|
||||||
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
|
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
|
||||||
if material_grade and material_grade != "UNKNOWN":
|
if material_grade and material_grade not in ["UNKNOWN", "UNCLASSIFIED"]:
|
||||||
db.execute(text("""
|
db.execute(text("""
|
||||||
UPDATE materials
|
UPDATE materials
|
||||||
SET material_grade = :new_material_grade
|
SET material_grade = :new_material_grade
|
||||||
@@ -1245,7 +1253,7 @@ async def upload_file(
|
|||||||
material_grade = material_info.get("grade", "")
|
material_grade = material_info.get("grade", "")
|
||||||
|
|
||||||
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
|
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
|
||||||
if material_grade and material_grade != "UNKNOWN":
|
if material_grade and material_grade not in ["UNKNOWN", "UNCLASSIFIED"]:
|
||||||
db.execute(text("""
|
db.execute(text("""
|
||||||
UPDATE materials
|
UPDATE materials
|
||||||
SET material_grade = :new_material_grade
|
SET material_grade = :new_material_grade
|
||||||
|
|||||||
@@ -8,11 +8,6 @@ from typing import Dict, List, Optional
|
|||||||
|
|
||||||
# ========== 제외 대상 타입 ==========
|
# ========== 제외 대상 타입 ==========
|
||||||
EXCLUDE_TYPES = {
|
EXCLUDE_TYPES = {
|
||||||
"WELD_GAP": {
|
|
||||||
"description_keywords": ["WELD GAP", "WELDING GAP", "GAP", "용접갭", "웰드갭"],
|
|
||||||
"characteristics": "용접 시 수축 고려용 계산 항목",
|
|
||||||
"reason": "실제 자재 아님 - 용접 갭 계산용"
|
|
||||||
},
|
|
||||||
"CUTTING_LOSS": {
|
"CUTTING_LOSS": {
|
||||||
"description_keywords": ["CUTTING LOSS", "CUT LOSS", "절단로스", "컷팅로스"],
|
"description_keywords": ["CUTTING LOSS", "CUT LOSS", "절단로스", "컷팅로스"],
|
||||||
"characteristics": "절단 시 손실 고려용 계산 항목",
|
"characteristics": "절단 시 손실 고려용 계산 항목",
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ def classify_material_integrated(description: str, main_nom: str = "",
|
|||||||
"reason": "스페셜 키워드 발견"
|
"reason": "스페셜 키워드 발견"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# VALVE 카테고리 우선 확인 (SIGHT GLASS, STRAINER)
|
# VALVE 카테고리 우선 확인 (SIGHT GLASS, STRAINER)
|
||||||
if ('SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or
|
if ('SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or
|
||||||
'사이트글라스' in desc_upper or '스트레이너' in desc_upper):
|
'사이트글라스' in desc_upper or '스트레이너' in desc_upper):
|
||||||
@@ -295,7 +296,7 @@ def classify_material_integrated(description: str, main_nom: str = "",
|
|||||||
|
|
||||||
# 분류 실패
|
# 분류 실패
|
||||||
return {
|
return {
|
||||||
"category": "UNKNOWN",
|
"category": "UNCLASSIFIED",
|
||||||
"confidence": 0.0,
|
"confidence": 0.0,
|
||||||
"evidence": ["NO_CLASSIFICATION_POSSIBLE"],
|
"evidence": ["NO_CLASSIFICATION_POSSIBLE"],
|
||||||
"classification_level": "NONE"
|
"classification_level": "NONE"
|
||||||
|
|||||||
@@ -91,10 +91,10 @@
|
|||||||
### `BOMManagementPage.jsx`
|
### `BOMManagementPage.jsx`
|
||||||
- **역할**: BOM(Bill of Materials) 통합 관리 페이지
|
- **역할**: BOM(Bill of Materials) 통합 관리 페이지
|
||||||
- **기능**:
|
- **기능**:
|
||||||
- 카테고리별 자재 조회 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT)
|
- 카테고리별 자재 조회 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT, SPECIAL)
|
||||||
- 자재 선택 및 구매신청 (엑셀 내보내기)
|
- 자재 선택 및 구매신청 (엑셀 내보내기)
|
||||||
- 구매신청된 자재 비활성화 표시
|
- 구매신청된 자재 비활성화 표시
|
||||||
- 사용자 요구사항 입력
|
- 사용자 요구사항 입력 및 저장
|
||||||
- 리비전 관리
|
- 리비전 관리
|
||||||
- **라우팅**: `/bom-management`
|
- **라우팅**: `/bom-management`
|
||||||
- **접근 권한**: 인증된 사용자
|
- **접근 권한**: 인증된 사용자
|
||||||
@@ -232,6 +232,25 @@
|
|||||||
- `GasketMaterialsView.jsx`: 가스켓 자재 관리
|
- `GasketMaterialsView.jsx`: 가스켓 자재 관리
|
||||||
- `BoltMaterialsView.jsx`: 볼트 자재 관리
|
- `BoltMaterialsView.jsx`: 볼트 자재 관리
|
||||||
- `SupportMaterialsView.jsx`: 서포트 자재 관리
|
- `SupportMaterialsView.jsx`: 서포트 자재 관리
|
||||||
|
- `SpecialMaterialsView.jsx`: 특수 제작 자재 관리
|
||||||
|
|
||||||
|
#### SPECIAL 카테고리 상세 기능
|
||||||
|
`SpecialMaterialsView.jsx`는 특수 제작이 필요한 자재들을 관리하는 컴포넌트입니다:
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- **자동 타입 분류**: FLANGE, OIL PUMP, COMPRESSOR, VALVE, FITTING, PIPE 등 큰 범주 자동 인식
|
||||||
|
- **정보 파싱**: 자재 설명을 도면, 항목1-4로 체계적 분리
|
||||||
|
- **테이블 구조**: `Type | Drawing | Item 1 | Item 2 | Item 3 | Item 4 | Additional Request | Purchase Quantity`
|
||||||
|
- **엑셀 내보내기**: P열 납기일 규칙 준수, 관리항목 자동 채움
|
||||||
|
- **저장 기능**: 추가요청사항 저장/편집 (다른 카테고리와 동일)
|
||||||
|
|
||||||
|
**처리 예시:**
|
||||||
|
- `SAE SPECIAL FF, OIL PUMP, ASTM A105` → Type: OIL PUMP, Item1: SAE SPECIAL FF, Item2: OIL PUMP, Item3: ASTM A105
|
||||||
|
- `FLG SPECIAL FF, COMPRESSOR(N11), ASTM A105` → Type: FLANGE, Item1: FLG SPECIAL FF, Item2: COMPRESSOR(N11), Item3: ASTM A105
|
||||||
|
|
||||||
|
**분류 조건:**
|
||||||
|
- `SPECIAL` 키워드 포함 (단, `SPECIFICATION` 제외)
|
||||||
|
- 한글 `스페셜` 또는 `SPL` 키워드 포함
|
||||||
|
|
||||||
### 기타 컴포넌트
|
### 기타 컴포넌트
|
||||||
- **NavigationMenu.jsx**: 사이드바 네비게이션
|
- **NavigationMenu.jsx**: 사이드바 네비게이션
|
||||||
@@ -258,6 +277,16 @@
|
|||||||
- **Background**: 글래스 효과 (backdrop-filter: blur)
|
- **Background**: 글래스 효과 (backdrop-filter: blur)
|
||||||
- **Cards**: 20px 둥근 모서리, 그림자 효과
|
- **Cards**: 20px 둥근 모서리, 그림자 효과
|
||||||
|
|
||||||
|
### BOM 카테고리 색상
|
||||||
|
- **PIPE**: #3b82f6 (파란색)
|
||||||
|
- **FITTING**: #10b981 (초록색)
|
||||||
|
- **FLANGE**: #f59e0b (주황색)
|
||||||
|
- **VALVE**: #ef4444 (빨간색)
|
||||||
|
- **GASKET**: #8b5cf6 (보라색)
|
||||||
|
- **BOLT**: #6b7280 (회색)
|
||||||
|
- **SUPPORT**: #f97316 (주황색)
|
||||||
|
- **SPECIAL**: #ec4899 (핑크색)
|
||||||
|
|
||||||
### 반응형 디자인
|
### 반응형 디자인
|
||||||
- **Desktop**: 3-4열 그리드
|
- **Desktop**: 3-4열 그리드
|
||||||
- **Tablet**: 2열 그리드
|
- **Tablet**: 2열 그리드
|
||||||
@@ -269,5 +298,14 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*마지막 업데이트: 2024-10-16*
|
*마지막 업데이트: 2024-10-17*
|
||||||
*다음 페이지 추가 시 반드시 이 문서를 업데이트하세요.*
|
*다음 페이지 추가 시 반드시 이 문서를 업데이트하세요.*
|
||||||
|
|
||||||
|
## 최근 업데이트 내역
|
||||||
|
|
||||||
|
### 2024-10-17: SPECIAL 카테고리 추가
|
||||||
|
- `SpecialMaterialsView.jsx` 컴포넌트 추가
|
||||||
|
- 특수 제작 자재 관리 기능 구현
|
||||||
|
- 자동 타입 분류 및 정보 파싱 시스템
|
||||||
|
- 엑셀 내보내기 규칙 적용 (P열 납기일, 관리항목 자동 채움)
|
||||||
|
- BOM 카테고리 색상 팔레트에 SPECIAL (#ec4899) 추가
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ const BoltMaterialsView = ({
|
|||||||
let boltGrade = '-';
|
let boltGrade = '-';
|
||||||
if (boltDetails.material_standard && boltDetails.material_grade) {
|
if (boltDetails.material_standard && boltDetails.material_grade) {
|
||||||
// bolt_details에서 완전한 재질 정보 구성
|
// bolt_details에서 완전한 재질 정보 구성
|
||||||
if (boltDetails.material_grade !== 'UNKNOWN' && boltDetails.material_grade !== boltDetails.material_standard) {
|
if (boltDetails.material_grade !== 'UNKNOWN' && boltDetails.material_grade !== 'UNCLASSIFIED' && boltDetails.material_grade !== boltDetails.material_standard) {
|
||||||
boltGrade = `${boltDetails.material_standard} ${boltDetails.material_grade}`;
|
boltGrade = `${boltDetails.material_standard} ${boltDetails.material_grade}`;
|
||||||
} else {
|
} else {
|
||||||
boltGrade = boltDetails.material_standard;
|
boltGrade = boltDetails.material_standard;
|
||||||
@@ -167,7 +167,7 @@ const BoltMaterialsView = ({
|
|||||||
|
|
||||||
// 볼트 타입 (PSV_BOLT, LT_BOLT 등)
|
// 볼트 타입 (PSV_BOLT, LT_BOLT 등)
|
||||||
let boltSubtype = 'BOLT_GENERAL';
|
let boltSubtype = 'BOLT_GENERAL';
|
||||||
if (boltDetails.bolt_type && boltDetails.bolt_type !== 'UNKNOWN') {
|
if (boltDetails.bolt_type && boltDetails.bolt_type !== 'UNKNOWN' && boltDetails.bolt_type !== 'UNCLASSIFIED') {
|
||||||
boltSubtype = boltDetails.bolt_type;
|
boltSubtype = boltDetails.bolt_type;
|
||||||
} else {
|
} else {
|
||||||
// 원본 설명에서 특수 볼트 타입 추출
|
// 원본 설명에서 특수 볼트 타입 추출
|
||||||
|
|||||||
@@ -308,10 +308,10 @@ const FittingMaterialsView = ({
|
|||||||
|
|
||||||
if (hasDifferentSchedules && mainSchedule && redSchedule) {
|
if (hasDifferentSchedules && mainSchedule && redSchedule) {
|
||||||
schedule = `${mainSchedule}×${redSchedule}`;
|
schedule = `${mainSchedule}×${redSchedule}`;
|
||||||
} else if (isReducingFitting && mainSchedule && mainSchedule !== 'UNKNOWN') {
|
} else if (isReducingFitting && mainSchedule && mainSchedule !== 'UNKNOWN' && mainSchedule !== 'UNCLASSIFIED') {
|
||||||
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
|
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
|
||||||
schedule = `${mainSchedule}×${mainSchedule}`;
|
schedule = `${mainSchedule}×${mainSchedule}`;
|
||||||
} else if (mainSchedule && mainSchedule !== 'UNKNOWN') {
|
} else if (mainSchedule && mainSchedule !== 'UNKNOWN' && mainSchedule !== 'UNCLASSIFIED') {
|
||||||
schedule = mainSchedule;
|
schedule = mainSchedule;
|
||||||
} else {
|
} else {
|
||||||
// Description에서 스케줄 추출 - 더 강력한 패턴 매칭
|
// Description에서 스케줄 추출 - 더 강력한 패턴 매칭
|
||||||
|
|||||||
628
frontend/src/components/bom/materials/SpecialMaterialsView.jsx
Normal file
628
frontend/src/components/bom/materials/SpecialMaterialsView.jsx
Normal file
@@ -0,0 +1,628 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||||
|
import api from '../../../api';
|
||||||
|
import { FilterableHeader } from '../shared';
|
||||||
|
|
||||||
|
const SpecialMaterialsView = ({
|
||||||
|
materials,
|
||||||
|
selectedMaterials,
|
||||||
|
setSelectedMaterials,
|
||||||
|
userRequirements,
|
||||||
|
setUserRequirements,
|
||||||
|
purchasedMaterials,
|
||||||
|
onPurchasedMaterialsUpdate,
|
||||||
|
updateMaterial, // 자재 업데이트 함수
|
||||||
|
jobNo,
|
||||||
|
fileId,
|
||||||
|
user,
|
||||||
|
onNavigate
|
||||||
|
}) => {
|
||||||
|
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||||
|
const [columnFilters, setColumnFilters] = useState({});
|
||||||
|
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||||
|
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||||
|
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||||
|
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loadSavedData = () => {
|
||||||
|
const savedRequestsData = {};
|
||||||
|
|
||||||
|
materials.forEach(material => {
|
||||||
|
if (material.user_requirement && material.user_requirement.trim()) {
|
||||||
|
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSavedRequests(savedRequestsData);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (materials && materials.length > 0) {
|
||||||
|
loadSavedData();
|
||||||
|
} else {
|
||||||
|
setSavedRequests({});
|
||||||
|
}
|
||||||
|
}, [materials]);
|
||||||
|
|
||||||
|
// 추가요구사항 저장 함수
|
||||||
|
const handleSaveRequest = async (materialId, request) => {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
try {
|
||||||
|
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||||
|
user_requirement: request.trim()
|
||||||
|
});
|
||||||
|
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||||
|
|
||||||
|
if (updateMaterial) {
|
||||||
|
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('추가요구사항 저장 실패:', error);
|
||||||
|
alert('추가요구사항 저장에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 추가요구사항 편집 시작
|
||||||
|
const handleEditRequest = (materialId, currentRequest) => {
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// SPECIAL 자재 정보 파싱
|
||||||
|
const parseSpecialInfo = (material) => {
|
||||||
|
const description = material.original_description || '';
|
||||||
|
const qty = Math.round(material.quantity || 0);
|
||||||
|
|
||||||
|
// Type 추출 (큰 범주: 우선순위 기반 분류)
|
||||||
|
let type = 'SPECIAL';
|
||||||
|
const descUpper = description.toUpperCase();
|
||||||
|
|
||||||
|
// 우선순위 1: 주요 장비 타입 (OIL PUMP, COMPRESSOR 등이 FLG보다 우선)
|
||||||
|
if (descUpper.includes('OIL PUMP') || (descUpper.includes('PUMP') && !descUpper.includes('FITTING'))) {
|
||||||
|
type = 'OIL PUMP';
|
||||||
|
} else if (descUpper.includes('COMPRESSOR')) {
|
||||||
|
type = 'COMPRESSOR';
|
||||||
|
} else if (descUpper.includes('VALVE') && !descUpper.includes('FLG')) {
|
||||||
|
type = 'VALVE';
|
||||||
|
}
|
||||||
|
// 우선순위 2: 구조물/부품 타입
|
||||||
|
else if (descUpper.includes('FLG') || descUpper.includes('FLANGE')) {
|
||||||
|
// FLG가 있어도 주요 장비가 함께 있으면 장비 타입 우선
|
||||||
|
if (descUpper.includes('OIL PUMP') || descUpper.includes('COMPRESSOR')) {
|
||||||
|
if (descUpper.includes('OIL PUMP')) {
|
||||||
|
type = 'OIL PUMP';
|
||||||
|
} else if (descUpper.includes('COMPRESSOR')) {
|
||||||
|
type = 'COMPRESSOR';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
type = 'FLANGE';
|
||||||
|
}
|
||||||
|
} else if (descUpper.includes('FITTING')) {
|
||||||
|
type = 'FITTING';
|
||||||
|
} else if (descUpper.includes('PIPE')) {
|
||||||
|
type = 'PIPE';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 도면 정보 (drawing_name 또는 line_no에서 추출)
|
||||||
|
const drawing = material.drawing_name || material.line_no || '-';
|
||||||
|
|
||||||
|
// 설명을 항목별로 분리 (콤마, 세미콜론, 파이프 등으로 구분)
|
||||||
|
const parts = description
|
||||||
|
.split(/[,;|\/]/)
|
||||||
|
.map(part => part.trim())
|
||||||
|
.filter(part => part.length > 0);
|
||||||
|
|
||||||
|
// 최대 4개 항목으로 제한
|
||||||
|
const detail1 = parts[0] || '-';
|
||||||
|
const detail2 = parts[1] || '-';
|
||||||
|
const detail3 = parts[2] || '-';
|
||||||
|
const detail4 = parts[3] || '-';
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
drawing,
|
||||||
|
detail1,
|
||||||
|
detail2,
|
||||||
|
detail3,
|
||||||
|
detail4,
|
||||||
|
quantity: qty,
|
||||||
|
originalQuantity: qty,
|
||||||
|
purchaseQuantity: qty,
|
||||||
|
isSpecial: 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 => {
|
||||||
|
const info = parseSpecialInfo(material);
|
||||||
|
|
||||||
|
// 컬럼 필터 적용
|
||||||
|
for (const [key, filterValue] of Object.entries(columnFilters)) {
|
||||||
|
if (filterValue && filterValue.trim()) {
|
||||||
|
const materialValue = String(info[key] || '').toLowerCase();
|
||||||
|
const filter = filterValue.toLowerCase();
|
||||||
|
if (!materialValue.includes(filter)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 정렬 적용
|
||||||
|
if (sortConfig.key) {
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const aInfo = parseSpecialInfo(a);
|
||||||
|
const bInfo = parseSpecialInfo(b);
|
||||||
|
|
||||||
|
const aValue = aInfo[sortConfig.key];
|
||||||
|
const bValue = bInfo[sortConfig.key];
|
||||||
|
|
||||||
|
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||||
|
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
|
||||||
|
// 전체 선택/해제
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
const selectableMaterials = filteredMaterials.filter(material =>
|
||||||
|
!purchasedMaterials.has(material.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const allSelected = selectableMaterials.every(material =>
|
||||||
|
selectedMaterials.has(material.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allSelected) {
|
||||||
|
// 전체 해제
|
||||||
|
const newSelected = new Set(selectedMaterials);
|
||||||
|
selectableMaterials.forEach(material => {
|
||||||
|
newSelected.delete(material.id);
|
||||||
|
});
|
||||||
|
setSelectedMaterials(newSelected);
|
||||||
|
} else {
|
||||||
|
// 전체 선택
|
||||||
|
const newSelected = new Set(selectedMaterials);
|
||||||
|
selectableMaterials.forEach(material => {
|
||||||
|
newSelected.add(material.id);
|
||||||
|
});
|
||||||
|
setSelectedMaterials(newSelected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개별 선택/해제
|
||||||
|
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 = `SPECIAL_Materials_${timestamp}.xlsx`;
|
||||||
|
|
||||||
|
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||||
|
...material,
|
||||||
|
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔄 스페셜 엑셀 내보내기 시작 - 새로운 방식');
|
||||||
|
|
||||||
|
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||||
|
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||||
|
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||||
|
category: 'SPECIAL',
|
||||||
|
filename: excelFileName,
|
||||||
|
user: user?.username || 'unknown'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 구매신청 생성
|
||||||
|
console.log('📝 구매신청 생성 중...');
|
||||||
|
const purchaseResponse = await api.post('/purchase-request/create', {
|
||||||
|
materials_data: dataWithRequirements,
|
||||||
|
file_id: fileId,
|
||||||
|
job_no: jobNo
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ 구매신청 생성 완료:', purchaseResponse.data);
|
||||||
|
|
||||||
|
// 3. 엑셀 파일을 서버에 업로드
|
||||||
|
console.log('📤 엑셀 파일 서버 업로드 중...');
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('excel_file', excelBlob, excelFileName);
|
||||||
|
formData.append('request_id', purchaseResponse.data.request_id);
|
||||||
|
formData.append('filename', excelFileName);
|
||||||
|
|
||||||
|
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||||
|
|
||||||
|
// 4. 구매신청된 자재들을 비활성화
|
||||||
|
const purchasedIds = selectedMaterialsData.map(m => m.id);
|
||||||
|
onPurchasedMaterialsUpdate(purchasedIds);
|
||||||
|
|
||||||
|
// 5. 선택 해제
|
||||||
|
setSelectedMaterials(new Set());
|
||||||
|
|
||||||
|
// 6. 클라이언트에서도 다운로드
|
||||||
|
const url = window.URL.createObjectURL(excelBlob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = excelFileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
console.log('✅ 스페셜 엑셀 내보내기 완료');
|
||||||
|
alert(`스페셜 자재 엑셀 파일이 생성되었습니다.\n파일명: ${excelFileName}`);
|
||||||
|
|
||||||
|
// 구매신청 관리 페이지로 이동
|
||||||
|
if (onNavigate) {
|
||||||
|
onNavigate('purchase-requests');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 스페셜 엑셀 내보내기 실패:', error);
|
||||||
|
alert('엑셀 내보내기 중 오류가 발생했습니다: ' + (error.response?.data?.detail || error.message));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const allSelected = filteredMaterials.length > 0 &&
|
||||||
|
filteredMaterials.filter(material => !purchasedMaterials.has(material.id))
|
||||||
|
.every(material => selectedMaterials.has(material.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '24px',
|
||||||
|
padding: '20px',
|
||||||
|
background: 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
color: 'white'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '24px', fontWeight: '700' }}>
|
||||||
|
Special Items
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: '8px 0 0 0', opacity: 0.9 }}>
|
||||||
|
특수 제작 품목 관리 ({filteredMaterials.length}개)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleExportToExcel}
|
||||||
|
disabled={selectedMaterials.size === 0}
|
||||||
|
style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
background: selectedMaterials.size > 0 ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.1)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.3)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
backdropFilter: 'blur(10px)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
구매신청 ({selectedMaterials.size})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: '600px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
minWidth: '1400px'
|
||||||
|
}}>
|
||||||
|
<div style={{ minWidth: '1400px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 150px 200px 200px 200px 200px 200px 250px 150px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
background: '#f8fafc',
|
||||||
|
borderBottom: '2px solid #e2e8f0',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#1f2937',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={handleSelectAll}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="type"
|
||||||
|
filterKey="type"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="drawing"
|
||||||
|
filterKey="drawing"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Drawing
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="detail1"
|
||||||
|
filterKey="detail1"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Detail 1
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="detail2"
|
||||||
|
filterKey="detail2"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Detail 2
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="detail3"
|
||||||
|
filterKey="detail3"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Detail 3
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="detail4"
|
||||||
|
filterKey="detail4"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Detail 4
|
||||||
|
</FilterableHeader>
|
||||||
|
<div>Additional Request</div>
|
||||||
|
<div>Purchase Quantity</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 행들 */}
|
||||||
|
<div>
|
||||||
|
{filteredMaterials.map((material, index) => {
|
||||||
|
const info = parseSpecialInfo(material);
|
||||||
|
const isSelected = selectedMaterials.has(material.id);
|
||||||
|
const isPurchased = purchasedMaterials.has(material.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={material.id}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 150px 200px 200px 200px 200px 200px 250px 150px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||||
|
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = '#f8fafc';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = 'white';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => handleMaterialSelect(material.id)}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||||
|
{info.type}
|
||||||
|
{isPurchased && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
background: '#fbbf24',
|
||||||
|
color: '#92400e',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}>
|
||||||
|
PURCHASED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.drawing}</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail1}</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail2}</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail3}</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail4}</div>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||||
|
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||||
|
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: '#f9fafb',
|
||||||
|
color: '#374151'
|
||||||
|
}}>
|
||||||
|
{savedRequests[material.id]}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userRequirements[material.id] || ''}
|
||||||
|
onChange={(e) => setUserRequirements({
|
||||||
|
...userRequirements,
|
||||||
|
[material.id]: e.target.value
|
||||||
|
})}
|
||||||
|
placeholder="Enter additional request..."
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||||
|
disabled={isPurchased || savingRequest[material.id]}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{savingRequest[material.id] ? '...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
|
{info.purchaseQuantity}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredMaterials.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '60px 20px',
|
||||||
|
color: '#6b7280',
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
marginTop: '20px'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📋</div>
|
||||||
|
<h3 style={{ margin: '0 0 8px 0', color: '#374151' }}>스페셜 자재가 없습니다</h3>
|
||||||
|
<p style={{ margin: 0 }}>특수 제작이 필요한 자재가 발견되면 여기에 표시됩니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpecialMaterialsView;
|
||||||
@@ -0,0 +1,561 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||||
|
import api from '../../../api';
|
||||||
|
import { FilterableHeader } from '../shared';
|
||||||
|
|
||||||
|
const UnclassifiedMaterialsView = ({
|
||||||
|
materials,
|
||||||
|
selectedMaterials,
|
||||||
|
setSelectedMaterials,
|
||||||
|
userRequirements,
|
||||||
|
setUserRequirements,
|
||||||
|
purchasedMaterials,
|
||||||
|
onPurchasedMaterialsUpdate,
|
||||||
|
updateMaterial, // 자재 업데이트 함수
|
||||||
|
jobNo,
|
||||||
|
fileId,
|
||||||
|
user,
|
||||||
|
onNavigate
|
||||||
|
}) => {
|
||||||
|
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||||
|
const [columnFilters, setColumnFilters] = useState({});
|
||||||
|
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||||
|
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||||
|
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||||
|
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loadSavedData = () => {
|
||||||
|
const savedRequestsData = {};
|
||||||
|
|
||||||
|
materials.forEach(material => {
|
||||||
|
if (material.user_requirement && material.user_requirement.trim()) {
|
||||||
|
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSavedRequests(savedRequestsData);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (materials && materials.length > 0) {
|
||||||
|
loadSavedData();
|
||||||
|
} else {
|
||||||
|
setSavedRequests({});
|
||||||
|
}
|
||||||
|
}, [materials]);
|
||||||
|
|
||||||
|
// 추가요구사항 저장 함수
|
||||||
|
const handleSaveRequest = async (materialId, request) => {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
try {
|
||||||
|
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||||
|
user_requirement: request.trim()
|
||||||
|
});
|
||||||
|
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||||
|
|
||||||
|
if (updateMaterial) {
|
||||||
|
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('추가요구사항 저장 실패:', error);
|
||||||
|
alert('추가요구사항 저장에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 추가요구사항 편집 시작
|
||||||
|
const handleEditRequest = (materialId, currentRequest) => {
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 미분류 자재 정보 파싱 (원본 그대로 표시)
|
||||||
|
const parseUnclassifiedInfo = (material) => {
|
||||||
|
const description = material.original_description || material.description || '';
|
||||||
|
const qty = Math.round(material.quantity || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: description || '-',
|
||||||
|
size: material.main_nom || material.size_spec || '-',
|
||||||
|
drawing: material.drawing_name || material.line_no || '-',
|
||||||
|
lineNo: material.line_no || '-',
|
||||||
|
quantity: qty,
|
||||||
|
originalQuantity: qty,
|
||||||
|
purchaseQuantity: qty,
|
||||||
|
isUnclassified: 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 => {
|
||||||
|
const info = parseUnclassifiedInfo(material);
|
||||||
|
|
||||||
|
// 컬럼 필터 적용
|
||||||
|
for (const [key, filterValue] of Object.entries(columnFilters)) {
|
||||||
|
if (filterValue && filterValue.trim()) {
|
||||||
|
const materialValue = String(info[key] || '').toLowerCase();
|
||||||
|
const filter = filterValue.toLowerCase();
|
||||||
|
if (!materialValue.includes(filter)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 정렬 적용
|
||||||
|
if (sortConfig.key) {
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const aInfo = parseUnclassifiedInfo(a);
|
||||||
|
const bInfo = parseUnclassifiedInfo(b);
|
||||||
|
|
||||||
|
const aValue = aInfo[sortConfig.key];
|
||||||
|
const bValue = bInfo[sortConfig.key];
|
||||||
|
|
||||||
|
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||||
|
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
|
||||||
|
// 전체 선택/해제
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
const selectableMaterials = filteredMaterials.filter(material =>
|
||||||
|
!purchasedMaterials.has(material.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const allSelected = selectableMaterials.every(material =>
|
||||||
|
selectedMaterials.has(material.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allSelected) {
|
||||||
|
// 전체 해제
|
||||||
|
const newSelected = new Set(selectedMaterials);
|
||||||
|
selectableMaterials.forEach(material => {
|
||||||
|
newSelected.delete(material.id);
|
||||||
|
});
|
||||||
|
setSelectedMaterials(newSelected);
|
||||||
|
} else {
|
||||||
|
// 전체 선택
|
||||||
|
const newSelected = new Set(selectedMaterials);
|
||||||
|
selectableMaterials.forEach(material => {
|
||||||
|
newSelected.add(material.id);
|
||||||
|
});
|
||||||
|
setSelectedMaterials(newSelected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개별 선택/해제
|
||||||
|
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 = `UNCLASSIFIED_Materials_${timestamp}.xlsx`;
|
||||||
|
|
||||||
|
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||||
|
...material,
|
||||||
|
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔄 미분류 엑셀 내보내기 시작 - 새로운 방식');
|
||||||
|
|
||||||
|
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||||
|
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||||
|
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||||
|
category: 'UNCLASSIFIED',
|
||||||
|
filename: excelFileName,
|
||||||
|
user: user?.username || 'unknown'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 구매신청 생성
|
||||||
|
console.log('📝 구매신청 생성 중...');
|
||||||
|
const purchaseResponse = await api.post('/purchase-request/create', {
|
||||||
|
materials_data: dataWithRequirements,
|
||||||
|
file_id: fileId,
|
||||||
|
job_no: jobNo
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ 구매신청 생성 완료:', purchaseResponse.data);
|
||||||
|
|
||||||
|
// 3. 엑셀 파일을 서버에 업로드
|
||||||
|
console.log('📤 엑셀 파일 서버 업로드 중...');
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('excel_file', excelBlob, excelFileName);
|
||||||
|
formData.append('request_id', purchaseResponse.data.request_id);
|
||||||
|
formData.append('filename', excelFileName);
|
||||||
|
|
||||||
|
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||||
|
|
||||||
|
// 4. 구매신청된 자재들을 비활성화
|
||||||
|
const purchasedIds = selectedMaterialsData.map(m => m.id);
|
||||||
|
onPurchasedMaterialsUpdate(purchasedIds);
|
||||||
|
|
||||||
|
// 5. 선택 해제
|
||||||
|
setSelectedMaterials(new Set());
|
||||||
|
|
||||||
|
// 6. 클라이언트에서도 다운로드
|
||||||
|
const url = window.URL.createObjectURL(excelBlob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = excelFileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
console.log('✅ 미분류 엑셀 내보내기 완료');
|
||||||
|
alert(`미분류 자재 엑셀 파일이 생성되었습니다.\n파일명: ${excelFileName}`);
|
||||||
|
|
||||||
|
// 구매신청 관리 페이지로 이동
|
||||||
|
if (onNavigate) {
|
||||||
|
onNavigate('purchase-requests');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 미분류 엑셀 내보내기 실패:', error);
|
||||||
|
alert('엑셀 내보내기 중 오류가 발생했습니다: ' + (error.response?.data?.detail || error.message));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const allSelected = filteredMaterials.length > 0 &&
|
||||||
|
filteredMaterials.filter(material => !purchasedMaterials.has(material.id))
|
||||||
|
.every(material => selectedMaterials.has(material.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '24px',
|
||||||
|
padding: '20px',
|
||||||
|
background: 'linear-gradient(135deg, #64748b 0%, #475569 100%)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
color: 'white'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '24px', fontWeight: '700' }}>
|
||||||
|
Unclassified Materials
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: '8px 0 0 0', opacity: 0.9 }}>
|
||||||
|
분류되지 않은 자재 관리 ({filteredMaterials.length}개)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleExportToExcel}
|
||||||
|
disabled={selectedMaterials.size === 0}
|
||||||
|
style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
background: selectedMaterials.size > 0 ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.1)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.3)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
backdropFilter: 'blur(10px)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
구매신청 ({selectedMaterials.size})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: '600px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
minWidth: '1200px'
|
||||||
|
}}>
|
||||||
|
<div style={{ minWidth: '1200px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 400px 150px 200px 150px 250px 150px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
background: '#f8fafc',
|
||||||
|
borderBottom: '2px solid #e2e8f0',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#1f2937',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={handleSelectAll}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="description"
|
||||||
|
filterKey="description"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="size"
|
||||||
|
filterKey="size"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Size
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="drawing"
|
||||||
|
filterKey="drawing"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Drawing
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="lineNo"
|
||||||
|
filterKey="lineNo"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Line No
|
||||||
|
</FilterableHeader>
|
||||||
|
<div>Additional Request</div>
|
||||||
|
<div>Purchase Quantity</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 행들 */}
|
||||||
|
<div>
|
||||||
|
{filteredMaterials.map((material, index) => {
|
||||||
|
const info = parseUnclassifiedInfo(material);
|
||||||
|
const isSelected = selectedMaterials.has(material.id);
|
||||||
|
const isPurchased = purchasedMaterials.has(material.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={material.id}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 400px 150px 200px 150px 250px 150px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||||
|
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = '#f8fafc';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = 'white';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => handleMaterialSelect(material.id)}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#1f2937',
|
||||||
|
textAlign: 'left',
|
||||||
|
paddingLeft: '8px',
|
||||||
|
wordBreak: 'break-word'
|
||||||
|
}}>
|
||||||
|
{info.description}
|
||||||
|
{isPurchased && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
background: '#fbbf24',
|
||||||
|
color: '#92400e',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}>
|
||||||
|
PURCHASED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.size}</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.drawing}</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.lineNo}</div>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||||
|
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||||
|
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: '#f9fafb',
|
||||||
|
color: '#374151'
|
||||||
|
}}>
|
||||||
|
{savedRequests[material.id]}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userRequirements[material.id] || ''}
|
||||||
|
onChange={(e) => setUserRequirements({
|
||||||
|
...userRequirements,
|
||||||
|
[material.id]: e.target.value
|
||||||
|
})}
|
||||||
|
placeholder="Enter additional request..."
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||||
|
disabled={isPurchased || savingRequest[material.id]}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{savingRequest[material.id] ? '...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
|
{info.purchaseQuantity}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredMaterials.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '60px 20px',
|
||||||
|
color: '#6b7280',
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
marginTop: '20px'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: '16px' }}>❓</div>
|
||||||
|
<h3 style={{ margin: '0 0 8px 0', color: '#374151' }}>미분류 자재가 없습니다</h3>
|
||||||
|
<p style={{ margin: 0 }}>분류되지 않은 자재가 발견되면 여기에 표시됩니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UnclassifiedMaterialsView;
|
||||||
@@ -6,3 +6,5 @@ export { default as ValveMaterialsView } from './ValveMaterialsView';
|
|||||||
export { default as GasketMaterialsView } from './GasketMaterialsView';
|
export { default as GasketMaterialsView } from './GasketMaterialsView';
|
||||||
export { default as BoltMaterialsView } from './BoltMaterialsView';
|
export { default as BoltMaterialsView } from './BoltMaterialsView';
|
||||||
export { default as SupportMaterialsView } from './SupportMaterialsView';
|
export { default as SupportMaterialsView } from './SupportMaterialsView';
|
||||||
|
export { default as SpecialMaterialsView } from './SpecialMaterialsView';
|
||||||
|
export { default as UnclassifiedMaterialsView } from './UnclassifiedMaterialsView';
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
ValveMaterialsView,
|
ValveMaterialsView,
|
||||||
GasketMaterialsView,
|
GasketMaterialsView,
|
||||||
BoltMaterialsView,
|
BoltMaterialsView,
|
||||||
SupportMaterialsView
|
SupportMaterialsView,
|
||||||
|
SpecialMaterialsView,
|
||||||
|
UnclassifiedMaterialsView
|
||||||
} from '../components/bom';
|
} from '../components/bom';
|
||||||
import './BOMManagementPage.css';
|
import './BOMManagementPage.css';
|
||||||
|
|
||||||
@@ -52,7 +54,9 @@ const BOMManagementPage = ({
|
|||||||
{ key: 'VALVE', label: 'Valves', color: '#ef4444' },
|
{ key: 'VALVE', label: 'Valves', color: '#ef4444' },
|
||||||
{ key: 'GASKET', label: 'Gaskets', color: '#8b5cf6' },
|
{ key: 'GASKET', label: 'Gaskets', color: '#8b5cf6' },
|
||||||
{ key: 'BOLT', label: 'Bolts', color: '#6b7280' },
|
{ key: 'BOLT', label: 'Bolts', color: '#6b7280' },
|
||||||
{ key: 'SUPPORT', label: 'Supports', color: '#f97316' }
|
{ key: 'SUPPORT', label: 'Supports', color: '#f97316' },
|
||||||
|
{ key: 'SPECIAL', label: 'Special Items', color: '#ec4899' },
|
||||||
|
{ key: 'UNCLASSIFIED', label: 'Unclassified', color: '#64748b' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// 자료 로드 함수들
|
// 자료 로드 함수들
|
||||||
@@ -208,6 +212,10 @@ const BOMManagementPage = ({
|
|||||||
return <BoltMaterialsView {...commonProps} />;
|
return <BoltMaterialsView {...commonProps} />;
|
||||||
case 'SUPPORT':
|
case 'SUPPORT':
|
||||||
return <SupportMaterialsView {...commonProps} />;
|
return <SupportMaterialsView {...commonProps} />;
|
||||||
|
case 'SPECIAL':
|
||||||
|
return <SpecialMaterialsView {...commonProps} />;
|
||||||
|
case 'UNCLASSIFIED':
|
||||||
|
return <UnclassifiedMaterialsView {...commonProps} />;
|
||||||
default:
|
default:
|
||||||
return <div>카테고리를 선택해주세요.</div>;
|
return <div>카테고리를 선택해주세요.</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -874,7 +874,7 @@
|
|||||||
background: #d97706;
|
background: #d97706;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* UNKNOWN 전용 헤더 - 5개 컬럼 */
|
/* UNCLASSIFIED 전용 헤더 - 5개 컬럼 */
|
||||||
.detailed-grid-header.unknown-header {
|
.detailed-grid-header.unknown-header {
|
||||||
grid-template-columns: 5% 10% 1fr 20% 10%;
|
grid-template-columns: 5% 10% 1fr 20% 10%;
|
||||||
|
|
||||||
@@ -891,7 +891,7 @@
|
|||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* UNKNOWN 전용 행 - 5개 컬럼 */
|
/* UNCLASSIFIED 전용 행 - 5개 컬럼 */
|
||||||
.detailed-material-row.unknown-row {
|
.detailed-material-row.unknown-row {
|
||||||
grid-template-columns: 5% 10% 1fr 20% 10%;
|
grid-template-columns: 5% 10% 1fr 20% 10%;
|
||||||
|
|
||||||
@@ -906,7 +906,7 @@
|
|||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* UNKNOWN 설명 셀 스타일 */
|
/* UNCLASSIFIED 설명 셀 스타일 */
|
||||||
.description-cell {
|
.description-cell {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
text-overflow: initial;
|
text-overflow: initial;
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ const NewMaterialsPage = ({
|
|||||||
const getCategoryCounts = () => {
|
const getCategoryCounts = () => {
|
||||||
const counts = {};
|
const counts = {};
|
||||||
materials.forEach(material => {
|
materials.forEach(material => {
|
||||||
const category = material.classified_category || 'UNKNOWN';
|
const category = material.classified_category || 'UNCLASSIFIED';
|
||||||
counts[category] = (counts[category] || 0) + 1;
|
counts[category] = (counts[category] || 0) + 1;
|
||||||
});
|
});
|
||||||
return counts;
|
return counts;
|
||||||
@@ -476,7 +476,7 @@ const NewMaterialsPage = ({
|
|||||||
'BOLT': 'BOLT',
|
'BOLT': 'BOLT',
|
||||||
'GASKET': 'GASKET',
|
'GASKET': 'GASKET',
|
||||||
'INSTRUMENT': 'INSTRUMENT',
|
'INSTRUMENT': 'INSTRUMENT',
|
||||||
'UNKNOWN': 'UNKNOWN'
|
'UNCLASSIFIED': 'UNCLASSIFIED'
|
||||||
};
|
};
|
||||||
return categoryMap[category] || category;
|
return categoryMap[category] || category;
|
||||||
};
|
};
|
||||||
@@ -1090,17 +1090,17 @@ const NewMaterialsPage = ({
|
|||||||
unit: '개',
|
unit: '개',
|
||||||
isSpecial: true
|
isSpecial: true
|
||||||
};
|
};
|
||||||
} else if (category === 'UNKNOWN') {
|
} else if (category === 'UNCLASSIFIED') {
|
||||||
return {
|
return {
|
||||||
type: 'UNKNOWN',
|
type: 'UNCLASSIFIED',
|
||||||
description: material.original_description || 'Unknown Item',
|
description: material.original_description || 'Unclassified Item',
|
||||||
quantity: Math.round(material.quantity || 0),
|
quantity: Math.round(material.quantity || 0),
|
||||||
unit: '개',
|
unit: '개',
|
||||||
isUnknown: true
|
isUnknown: true
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
type: category || 'UNKNOWN',
|
type: category || 'UNCLASSIFIED',
|
||||||
subtype: '-',
|
subtype: '-',
|
||||||
size: material.size_spec || '-',
|
size: material.size_spec || '-',
|
||||||
schedule: '-',
|
schedule: '-',
|
||||||
@@ -1939,7 +1939,7 @@ const NewMaterialsPage = ({
|
|||||||
<div>사용자요구</div>
|
<div>사용자요구</div>
|
||||||
<FilterableHeader sortKey="quantity" filterKey="quantity">수량</FilterableHeader>
|
<FilterableHeader sortKey="quantity" filterKey="quantity">수량</FilterableHeader>
|
||||||
</div>
|
</div>
|
||||||
) : selectedCategory === 'UNKNOWN' ? (
|
) : selectedCategory === 'UNCLASSIFIED' ? (
|
||||||
<div className="detailed-grid-header unknown-header">
|
<div className="detailed-grid-header unknown-header">
|
||||||
<div>선택</div>
|
<div>선택</div>
|
||||||
<div>종류</div>
|
<div>종류</div>
|
||||||
@@ -2464,7 +2464,7 @@ const NewMaterialsPage = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (material.classified_category === 'UNKNOWN') {
|
if (material.classified_category === 'UNCLASSIFIED') {
|
||||||
// 구매신청 여부 확인
|
// 구매신청 여부 확인
|
||||||
const isPurchased = material.grouped_ids ?
|
const isPurchased = material.grouped_ids ?
|
||||||
material.grouped_ids.some(id => purchasedMaterials.has(id)) :
|
material.grouped_ids.some(id => purchasedMaterials.has(id)) :
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) =>
|
|||||||
{(() => {
|
{(() => {
|
||||||
// 카테고리별로 자재 그룹화
|
// 카테고리별로 자재 그룹화
|
||||||
const groupedByCategory = requestMaterials.reduce((acc, material) => {
|
const groupedByCategory = requestMaterials.reduce((acc, material) => {
|
||||||
const category = material.category || material.classified_category || 'UNKNOWN';
|
const category = material.category || material.classified_category || 'UNCLASSIFIED';
|
||||||
if (!acc[category]) acc[category] = [];
|
if (!acc[category]) acc[category] = [];
|
||||||
acc[category].push(material);
|
acc[category].push(material);
|
||||||
return acc;
|
return acc;
|
||||||
|
|||||||
@@ -522,7 +522,7 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
|||||||
// BOM 페이지의 타입을 따라가도록 - 프론트엔드 parseBoltInfo와 동일한 로직
|
// BOM 페이지의 타입을 따라가도록 - 프론트엔드 parseBoltInfo와 동일한 로직
|
||||||
let boltSubtype = 'BOLT_GENERAL';
|
let boltSubtype = 'BOLT_GENERAL';
|
||||||
|
|
||||||
if (boltType && boltType !== 'UNKNOWN') {
|
if (boltType && boltType !== 'UNKNOWN' && boltType !== 'UNCLASSIFIED') {
|
||||||
boltSubtype = boltType;
|
boltSubtype = boltType;
|
||||||
} else {
|
} else {
|
||||||
// 원본 설명에서 특수 볼트 타입 추출 (프론트엔드와 동일)
|
// 원본 설명에서 특수 볼트 타입 추출 (프론트엔드와 동일)
|
||||||
@@ -565,8 +565,29 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
|||||||
} else {
|
} else {
|
||||||
itemName = 'SUPPORT';
|
itemName = 'SUPPORT';
|
||||||
}
|
}
|
||||||
|
} else if (category === 'SPECIAL') {
|
||||||
|
// 스페셜 자재 타입 추출 (큰 범주)
|
||||||
|
const desc = cleanDescription.toUpperCase();
|
||||||
|
if (desc.includes('FLG') || desc.includes('FLANGE')) {
|
||||||
|
itemName = 'SPECIAL FLANGE';
|
||||||
|
} else if (desc.includes('OIL PUMP') || desc.includes('PUMP')) {
|
||||||
|
itemName = 'SPECIAL OIL PUMP';
|
||||||
|
} else if (desc.includes('COMPRESSOR')) {
|
||||||
|
itemName = 'SPECIAL COMPRESSOR';
|
||||||
|
} else if (desc.includes('VALVE')) {
|
||||||
|
itemName = 'SPECIAL VALVE';
|
||||||
|
} else if (desc.includes('FITTING')) {
|
||||||
|
itemName = 'SPECIAL FITTING';
|
||||||
|
} else if (desc.includes('PIPE')) {
|
||||||
|
itemName = 'SPECIAL PIPE';
|
||||||
|
} else {
|
||||||
|
itemName = 'SPECIAL ITEM';
|
||||||
|
}
|
||||||
|
} else if (category === 'UNCLASSIFIED') {
|
||||||
|
// 미분류 자재는 원본 설명을 품목명으로 사용
|
||||||
|
itemName = cleanDescription || 'UNCLASSIFIED ITEM';
|
||||||
} else {
|
} else {
|
||||||
itemName = category || 'UNKNOWN';
|
itemName = category || 'UNCLASSIFIED';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 압력 등급 추출 (카테고리별 처리)
|
// 압력 등급 추출 (카테고리별 처리)
|
||||||
@@ -950,6 +971,36 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
|||||||
base['관리항목4'] = ''; // M열
|
base['관리항목4'] = ''; // M열
|
||||||
base['관리항목5'] = ''; // N열
|
base['관리항목5'] = ''; // N열
|
||||||
base['관리항목6'] = ''; // O열
|
base['관리항목6'] = ''; // O열
|
||||||
|
} else if (category === 'SPECIAL') {
|
||||||
|
// 스페셜 전용 컬럼 (F~O) - Type은 품목명에 포함, 나머지는 항목별로 분리
|
||||||
|
const description = cleanDescription || '';
|
||||||
|
const parts = description
|
||||||
|
.split(/[,;|\/]/)
|
||||||
|
.map(part => part.trim())
|
||||||
|
.filter(part => part.length > 0);
|
||||||
|
|
||||||
|
base['도면'] = material.drawing_name || material.line_no || '-'; // F열
|
||||||
|
base['상세1'] = parts[0] || '-'; // G열
|
||||||
|
base['상세2'] = parts[1] || '-'; // H열
|
||||||
|
base['상세3'] = parts[2] || '-'; // I열
|
||||||
|
base['상세4'] = parts[3] || '-'; // J열
|
||||||
|
base['추가요청사항'] = material.user_requirement || ''; // K열
|
||||||
|
base['관리항목1'] = ''; // L열
|
||||||
|
base['관리항목2'] = ''; // M열
|
||||||
|
base['관리항목3'] = ''; // N열
|
||||||
|
base['관리항목4'] = ''; // O열
|
||||||
|
} else if (category === 'UNCLASSIFIED') {
|
||||||
|
// 미분류 전용 컬럼 (F~O) - 원본 정보 그대로 표시
|
||||||
|
base['크기'] = material.main_nom || material.size_spec || '-'; // F열
|
||||||
|
base['도면'] = material.drawing_name || '-'; // G열
|
||||||
|
base['라인번호'] = material.line_no || '-'; // H열
|
||||||
|
base['추가요청사항'] = material.user_requirement || ''; // I열
|
||||||
|
base['관리항목1'] = ''; // J열
|
||||||
|
base['관리항목2'] = ''; // K열
|
||||||
|
base['관리항목3'] = ''; // L열
|
||||||
|
base['관리항목4'] = ''; // M열
|
||||||
|
base['관리항목5'] = ''; // N열
|
||||||
|
base['관리항목6'] = ''; // O열
|
||||||
} else {
|
} else {
|
||||||
// 기타 카테고리 기본 컬럼 (F~O) - 타입 제거, 품목명에 포함됨
|
// 기타 카테고리 기본 컬럼 (F~O) - 타입 제거, 품목명에 포함됨
|
||||||
base['크기'] = material.size_spec || '-'; // F열
|
base['크기'] = material.size_spec || '-'; // F열
|
||||||
@@ -1213,6 +1264,8 @@ export const createExcelBlob = async (materials, filename, options = {}) => {
|
|||||||
'GASKET': ['크기', '압력등급', '구조', '재질1', '재질2', '두께', '사용자요구', '추가요청사항', '관리항목1', '관리항목2'],
|
'GASKET': ['크기', '압력등급', '구조', '재질1', '재질2', '두께', '사용자요구', '추가요청사항', '관리항목1', '관리항목2'],
|
||||||
'BOLT': ['크기', '압력등급', '길이', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
|
'BOLT': ['크기', '압력등급', '길이', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
|
||||||
'SUPPORT': ['크기', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4', '관리항목5', '관리항목6'],
|
'SUPPORT': ['크기', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4', '관리항목5', '관리항목6'],
|
||||||
|
'SPECIAL': ['도면', '상세1', '상세2', '상세3', '상세4', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
|
||||||
|
'UNCLASSIFIED': ['크기', '도면', '라인번호', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4', '관리항목5', '관리항목6'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const deliveryDateHeader = '납기일(YYYY-MM-DD)';
|
const deliveryDateHeader = '납기일(YYYY-MM-DD)';
|
||||||
|
|||||||
Reference in New Issue
Block a user