diff --git a/backend/app/main.py b/backend/app/main.py index dd3e7ff..fbcfa74 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -119,6 +119,14 @@ try: except ImportError as e: logger.warning(f"purchase_request 라우터를 찾을 수 없습니다: {e}") +# 자재 관리 라우터 +try: + from .routers import materials + app.include_router(materials.router) + logger.info("materials 라우터 등록 완료") +except ImportError as e: + logger.warning(f"materials 라우터를 찾을 수 없습니다: {e}") + # 파일 관리 API 라우터 등록 (비활성화 - files 라우터와 충돌 방지) # try: # from .api import file_management diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index e5921b4..6450617 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -1867,6 +1867,7 @@ async def get_materials( m.created_at, m.classified_category, m.classification_confidence, m.classification_details, m.is_verified, m.verified_by, m.verified_at, + m.brand, m.user_requirement, f.original_filename, f.project_id, f.job_no, f.revision, p.official_project_code, p.project_name, pd.outer_diameter, pd.schedule, pd.material_spec, pd.manufacturing_method, @@ -2087,7 +2088,10 @@ async def get_materials( "purchase_status": m.purchase_status, "purchase_confirmed_by": m.confirmed_by, "purchase_confirmed_at": m.confirmed_at, - "created_at": m.created_at + "created_at": m.created_at, + # 브랜드와 사용자 요구사항 필드 추가 + "brand": m.brand, + "user_requirement": m.user_requirement } # 카테고리별 상세 정보 추가 (JOIN 결과 사용) diff --git a/backend/app/routers/materials.py b/backend/app/routers/materials.py new file mode 100644 index 0000000..daa69e4 --- /dev/null +++ b/backend/app/routers/materials.py @@ -0,0 +1,161 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from sqlalchemy import text +from pydantic import BaseModel +from ..database import get_db +from ..auth.middleware import get_current_user + +router = APIRouter(prefix="/materials", tags=["materials"]) + +class BrandUpdate(BaseModel): + brand: str + +class UserRequirementUpdate(BaseModel): + user_requirement: str + +@router.patch("/{material_id}/brand") +async def update_material_brand( + material_id: int, + brand_data: BrandUpdate, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """자재의 브랜드 정보를 업데이트합니다.""" + try: + # 자재 존재 여부 확인 + result = db.execute( + text("SELECT id FROM materials WHERE id = :material_id"), + {"material_id": material_id} + ) + material = result.fetchone() + + if not material: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="자재를 찾을 수 없습니다." + ) + + # 브랜드 업데이트 + db.execute( + text(""" + UPDATE materials + SET brand = :brand, + updated_by = :updated_by + WHERE id = :material_id + """), + { + "brand": brand_data.brand.strip(), + "updated_by": current_user.get("username", "unknown"), + "material_id": material_id + } + ) + + db.commit() + + return { + "success": True, + "message": "브랜드가 성공적으로 업데이트되었습니다.", + "material_id": material_id, + "brand": brand_data.brand.strip() + } + + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"브랜드 업데이트 실패: {str(e)}" + ) + +@router.patch("/{material_id}/user-requirement") +async def update_material_user_requirement( + material_id: int, + requirement_data: UserRequirementUpdate, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """자재의 사용자 요구사항을 업데이트합니다.""" + try: + # 자재 존재 여부 확인 + result = db.execute( + text("SELECT id FROM materials WHERE id = :material_id"), + {"material_id": material_id} + ) + material = result.fetchone() + + if not material: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="자재를 찾을 수 없습니다." + ) + + # 사용자 요구사항 업데이트 + db.execute( + text(""" + UPDATE materials + SET user_requirement = :user_requirement, + updated_by = :updated_by + WHERE id = :material_id + """), + { + "user_requirement": requirement_data.user_requirement.strip(), + "updated_by": current_user.get("username", "unknown"), + "material_id": material_id + } + ) + + db.commit() + + return { + "success": True, + "message": "사용자 요구사항이 성공적으로 업데이트되었습니다.", + "material_id": material_id, + "user_requirement": requirement_data.user_requirement.strip() + } + + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"사용자 요구사항 업데이트 실패: {str(e)}" + ) + +@router.get("/{material_id}") +async def get_material( + material_id: int, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """자재 정보를 조회합니다.""" + try: + result = db.execute( + text(""" + SELECT id, original_description, classified_category, + brand, user_requirement, created_at, updated_by + FROM materials + WHERE id = :material_id + """), + {"material_id": material_id} + ) + material = result.fetchone() + + if not material: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="자재를 찾을 수 없습니다." + ) + + return { + "id": material.id, + "original_description": material.original_description, + "classified_category": material.classified_category, + "brand": material.brand, + "user_requirement": material.user_requirement, + "created_at": material.created_at, + "updated_by": material.updated_by + } + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"자재 조회 실패: {str(e)}" + ) diff --git a/frontend/src/components/bom/materials/BoltMaterialsView.jsx b/frontend/src/components/bom/materials/BoltMaterialsView.jsx index d900e28..1953a8a 100644 --- a/frontend/src/components/bom/materials/BoltMaterialsView.jsx +++ b/frontend/src/components/bom/materials/BoltMaterialsView.jsx @@ -11,6 +11,7 @@ const BoltMaterialsView = ({ setUserRequirements, purchasedMaterials, onPurchasedMaterialsUpdate, + updateMaterial, // 자재 업데이트 함수 jobNo, fileId, user @@ -18,6 +19,59 @@ const BoltMaterialsView = ({ 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 extractBoltAdditionalRequirements = (description) => { const additionalReqs = []; @@ -252,7 +306,7 @@ const BoltMaterialsView = ({ const dataWithRequirements = selectedMaterialsData.map(material => ({ ...material, - user_requirement: userRequirements[material.id] || '' + user_requirement: savedRequests[material.id] || userRequirements[material.id] || '' })); try { @@ -407,7 +461,7 @@ const BoltMaterialsView = ({ {/* 헤더 */}
{info.userRequirements}
-
- setUserRequirements({ - ...userRequirements, - [material.id]: e.target.value - })} - placeholder="Enter additional request..." - style={{ - width: '100%', - padding: '6px 8px', - border: '1px solid #d1d5db', - borderRadius: '4px', - fontSize: '12px' - }} - /> +
+ {!editingRequest[material.id] && savedRequests[material.id] ? ( + // 저장된 상태 - 요구사항 표시 + 수정 버튼 + <> +
+ {savedRequests[material.id]} +
+ + + ) : ( + // 편집 상태 - 입력 필드 + 저장 버튼 + <> + 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' + }} + /> + + + )}
{info.purchaseQuantity} diff --git a/frontend/src/components/bom/materials/FittingMaterialsView.jsx b/frontend/src/components/bom/materials/FittingMaterialsView.jsx index 6e0f7ee..660be0c 100644 --- a/frontend/src/components/bom/materials/FittingMaterialsView.jsx +++ b/frontend/src/components/bom/materials/FittingMaterialsView.jsx @@ -11,6 +11,7 @@ const FittingMaterialsView = ({ setUserRequirements, purchasedMaterials, onPurchasedMaterialsUpdate, + updateMaterial, // 자재 업데이트 함수 fileId, jobNo, user, @@ -19,6 +20,58 @@ const FittingMaterialsView = ({ const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); const [columnFilters, setColumnFilters] = useState({}); const [showFilterDropdown, setShowFilterDropdown] = useState(null); + const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태 + const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태 + const [editingRequest, setEditingRequest] = 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 extractNippleEndInfo = (description) => { @@ -408,7 +461,7 @@ const FittingMaterialsView = ({ const dataWithRequirements = selectedMaterialsData.map(material => ({ ...material, - user_requirement: userRequirements[material.id] || '' + user_requirement: savedRequests[material.id] || userRequirements[material.id] || '' })); try { @@ -627,7 +680,7 @@ const FittingMaterialsView = ({ {/* 헤더 */}
{info.quantity} {info.unit}
-
- setUserRequirements({ - ...userRequirements, - [material.id]: e.target.value - })} - placeholder="Enter additional request..." - style={{ - width: '100%', - padding: '6px 8px', - border: '1px solid #d1d5db', - borderRadius: '4px', - fontSize: '12px' - }} - /> +
+ {!editingRequest[material.id] && savedRequests[material.id] ? ( + // 저장된 상태 - 요구사항 표시 + 수정 버튼 + <> +
+ {savedRequests[material.id]} +
+ + + ) : ( + // 편집 상태 - 입력 필드 + 저장 버튼 + <> + 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' + }} + /> + + + )}
); diff --git a/frontend/src/components/bom/materials/FlangeMaterialsView.jsx b/frontend/src/components/bom/materials/FlangeMaterialsView.jsx index 2b52695..502e2fe 100644 --- a/frontend/src/components/bom/materials/FlangeMaterialsView.jsx +++ b/frontend/src/components/bom/materials/FlangeMaterialsView.jsx @@ -11,6 +11,7 @@ const FlangeMaterialsView = ({ setUserRequirements, purchasedMaterials, onPurchasedMaterialsUpdate, + updateMaterial, // 자재 업데이트 함수 fileId, jobNo, user, @@ -19,6 +20,58 @@ const FlangeMaterialsView = ({ 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 parseFlangeInfo = (material) => { @@ -238,7 +291,7 @@ const FlangeMaterialsView = ({ const dataWithRequirements = selectedMaterialsData.map(material => ({ ...material, - user_requirement: userRequirements[material.id] || '' + user_requirement: savedRequests[material.id] || userRequirements[material.id] || '' })); try { @@ -456,7 +509,7 @@ const FlangeMaterialsView = ({ {/* 헤더 */}
{info.quantity} {info.unit}
-
- setUserRequirements({ - ...userRequirements, - [material.id]: e.target.value - })} - placeholder="Enter additional request..." - style={{ - width: '100%', - padding: '6px 8px', - border: '1px solid #d1d5db', - borderRadius: '4px', - fontSize: '12px' - }} - /> +
+ {!editingRequest[material.id] && savedRequests[material.id] ? ( + // 저장된 상태 - 요구사항 표시 + 수정 버튼 + <> +
+ {savedRequests[material.id]} +
+ + + ) : ( + // 편집 상태 - 입력 필드 + 저장 버튼 + <> + 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' + }} + /> + + + )}
); diff --git a/frontend/src/components/bom/materials/GasketMaterialsView.jsx b/frontend/src/components/bom/materials/GasketMaterialsView.jsx index 6a7d583..1c6734d 100644 --- a/frontend/src/components/bom/materials/GasketMaterialsView.jsx +++ b/frontend/src/components/bom/materials/GasketMaterialsView.jsx @@ -11,6 +11,7 @@ const GasketMaterialsView = ({ setUserRequirements, purchasedMaterials, onPurchasedMaterialsUpdate, + updateMaterial, // 자재 업데이트 함수 fileId, jobNo, user @@ -18,6 +19,58 @@ const GasketMaterialsView = ({ 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 parseGasketInfo = (material) => { const qty = Math.round(material.quantity || 0); @@ -189,7 +242,7 @@ const GasketMaterialsView = ({ const dataWithRequirements = selectedMaterialsData.map(material => ({ ...material, - user_requirement: userRequirements[material.id] || '' + user_requirement: savedRequests[material.id] || userRequirements[material.id] || '' })); try { @@ -341,7 +394,7 @@ const GasketMaterialsView = ({ {/* 헤더 */}
{info.userRequirements}
-
- setUserRequirements({ - ...userRequirements, - [material.id]: e.target.value - })} - placeholder="Enter additional request..." - style={{ - width: '100%', - padding: '6px 8px', - border: '1px solid #d1d5db', - borderRadius: '4px', - fontSize: '12px' - }} - /> +
+ {!editingRequest[material.id] && savedRequests[material.id] ? ( + // 저장된 상태 - 요구사항 표시 + 수정 버튼 + <> +
+ {savedRequests[material.id]} +
+ + + ) : ( + // 편집 상태 - 입력 필드 + 저장 버튼 + <> + 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' + }} + /> + + + )}
{info.purchaseQuantity.toLocaleString()} diff --git a/frontend/src/components/bom/materials/PipeMaterialsView.jsx b/frontend/src/components/bom/materials/PipeMaterialsView.jsx index cd27f09..063ed56 100644 --- a/frontend/src/components/bom/materials/PipeMaterialsView.jsx +++ b/frontend/src/components/bom/materials/PipeMaterialsView.jsx @@ -11,6 +11,7 @@ const PipeMaterialsView = ({ setUserRequirements, purchasedMaterials, onPurchasedMaterialsUpdate, + updateMaterial, // 자재 업데이트 함수 fileId, jobNo, user, @@ -19,6 +20,97 @@ const PipeMaterialsView = ({ const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); const [columnFilters, setColumnFilters] = useState({}); const [showFilterDropdown, setShowFilterDropdown] = useState(null); + const [brandInputs, setBrandInputs] = useState({}); // 브랜드 입력 상태 + const [savingBrand, setSavingBrand] = useState({}); // 브랜드 저장 중 상태 + const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태 + const [savedBrands, setSavedBrands] = useState({}); // 저장된 브랜드 상태 + const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태 + const [editingBrand, setEditingBrand] = useState({}); // 브랜드 편집 모드 + const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드 + + // 컴포넌트 마운트 시 저장된 데이터 로드 + React.useEffect(() => { + const loadSavedData = () => { + const savedBrandsData = {}; + const savedRequestsData = {}; + + materials.forEach(material => { + // 백엔드에서 가져온 데이터가 있으면 저장된 상태로 설정 + if (material.brand && material.brand.trim()) { + savedBrandsData[material.id] = material.brand.trim(); + } + if (material.user_requirement && material.user_requirement.trim()) { + savedRequestsData[material.id] = material.user_requirement.trim(); + } + }); + + setSavedBrands(savedBrandsData); + setSavedRequests(savedRequestsData); + }; + + if (materials && materials.length > 0) { + loadSavedData(); + } else { + setSavedBrands({}); + setSavedRequests({}); + } + }, [materials]); + + // 브랜드 저장 함수 + const handleSaveBrand = async (materialId, brand) => { + if (!brand.trim()) return; + + setSavingBrand(prev => ({ ...prev, [materialId]: true })); + try { + await api.patch(`/materials/${materialId}/brand`, { brand: brand.trim() }); + setSavedBrands(prev => ({ ...prev, [materialId]: brand.trim() })); + setEditingBrand(prev => ({ ...prev, [materialId]: false })); + setBrandInputs(prev => ({ ...prev, [materialId]: '' })); + + if (updateMaterial) { + updateMaterial(materialId, { brand: brand.trim() }); + } + } catch (error) { + console.error('브랜드 저장 실패:', error); + alert('브랜드 저장에 실패했습니다.'); + } finally { + setSavingBrand(prev => ({ ...prev, [materialId]: false })); + } + }; + + // 브랜드 편집 시작 + const handleEditBrand = (materialId, currentBrand) => { + setEditingBrand(prev => ({ ...prev, [materialId]: true })); + setBrandInputs(prev => ({ ...prev, [materialId]: currentBrand || '' })); + }; + + // 추가요구사항 저장 함수 + 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 calculatePipePurchase = (material) => { @@ -96,6 +188,7 @@ const PipeMaterialsView = ({ }; }; + // 정렬 처리 const handleSort = (key) => { let direction = 'asc'; @@ -175,7 +268,7 @@ const PipeMaterialsView = ({ // 사용자 요구사항 포함 const dataWithRequirements = selectedMaterialsData.map(material => ({ ...material, - user_requirement: userRequirements[material.id] || '' + user_requirement: savedRequests[material.id] || userRequirements[material.id] || '' })); try { @@ -397,7 +490,7 @@ const PipeMaterialsView = ({ {/* 헤더 */}
{info.unit}
-
- setUserRequirements({ - ...userRequirements, - [material.id]: e.target.value - })} - placeholder="Enter additional request..." - style={{ - width: '100%', - padding: '6px 8px', - border: '1px solid #d1d5db', - borderRadius: '4px', - fontSize: '12px' - }} - /> +
+ {!editingRequest[material.id] && savedRequests[material.id] ? ( + // 저장된 상태 - 요구사항 표시 + 수정 버튼 + <> +
+ {savedRequests[material.id]} +
+ + + ) : ( + // 편집 상태 - 입력 필드 + 저장 버튼 + <> + setUserRequirements({ + ...userRequirements, + [material.id]: e.target.value + })} + placeholder="Enter additional request..." + disabled={isPurchased} + style={{ + flex: 1, + padding: '6px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '11px', + opacity: isPurchased ? 0.5 : 1, + cursor: isPurchased ? 'not-allowed' : 'text' + }} + /> + + + )}
); diff --git a/frontend/src/components/bom/materials/SupportMaterialsView.jsx b/frontend/src/components/bom/materials/SupportMaterialsView.jsx index 32352ea..aef43e3 100644 --- a/frontend/src/components/bom/materials/SupportMaterialsView.jsx +++ b/frontend/src/components/bom/materials/SupportMaterialsView.jsx @@ -11,6 +11,7 @@ const SupportMaterialsView = ({ setUserRequirements, purchasedMaterials, onPurchasedMaterialsUpdate, + updateMaterial, // 자재 업데이트 함수 jobNo, fileId, user @@ -18,6 +19,58 @@ const SupportMaterialsView = ({ 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 parseSupportInfo = (material) => { const desc = material.original_description || ''; @@ -29,7 +82,13 @@ const SupportMaterialsView = ({ if (descUpper.includes('URETHANE') || descUpper.includes('BLOCK SHOE') || descUpper.includes('우레탄')) { supportType = 'URETHANE BLOCK SHOE'; } else if (descUpper.includes('CLAMP') || descUpper.includes('클램프')) { - supportType = 'CLAMP'; + // 클램프 타입 상세 분류 (CL-1, CL-2, CL-3 등) + const clampMatch = desc.match(/CL[-\s]*(\d+)/i); + if (clampMatch) { + supportType = `CLAMP CL-${clampMatch[1]}`; + } else { + supportType = 'CLAMP CL-1'; // 기본값 + } } else if (descUpper.includes('HANGER') || descUpper.includes('행거')) { supportType = 'HANGER'; } else if (descUpper.includes('SPRING') || descUpper.includes('스프링')) { @@ -86,6 +145,9 @@ const SupportMaterialsView = ({ if (!consolidated[key]) { consolidated[key] = { ...material, + // Material Grade 정보를 parsedInfo에서 가져와서 설정 + material_grade: info.grade, + full_material_grade: info.grade, consolidatedQuantity: info.originalQuantity, consolidatedIds: [material.id], parsedInfo: info @@ -208,8 +270,13 @@ const SupportMaterialsView = ({ // 엑셀 내보내기 const handleExportToExcel = async () => { - const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id)); - if (selectedMaterialsData.length === 0) { + // 선택된 합산 자료 가져오기 + const filteredMaterials = getFilteredAndSortedMaterials(); + const selectedConsolidatedMaterials = filteredMaterials.filter(consolidatedMaterial => + consolidatedMaterial.consolidatedIds.some(id => selectedMaterials.has(id)) + ); + + if (selectedConsolidatedMaterials.length === 0) { alert('내보낼 자재를 선택해주세요.'); return; } @@ -217,9 +284,13 @@ const SupportMaterialsView = ({ 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] || '' + // 합산된 자료를 엑셀 형태로 변환 + const dataWithRequirements = selectedConsolidatedMaterials.map(consolidatedMaterial => ({ + ...consolidatedMaterial, + // 합산된 수량으로 덮어쓰기 + quantity: consolidatedMaterial.consolidatedQuantity, + // 사용자 요구사항은 대표 ID 기준 (저장된 데이터 우선) + user_requirement: savedRequests[consolidatedMaterial.id] || userRequirements[consolidatedMaterial.id] || '' })); try { @@ -235,7 +306,7 @@ const SupportMaterialsView = ({ console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes'); // 2. 구매신청 생성 - const allMaterialIds = selectedMaterialsData.map(m => m.id); + const allMaterialIds = selectedConsolidatedMaterials.flatMap(cm => cm.consolidatedIds); const response = await api.post('/purchase-request/create', { file_id: fileId, job_no: jobNo, @@ -248,7 +319,7 @@ const SupportMaterialsView = ({ size: m.size_inch || m.size_spec, schedule: m.schedule, material_grade: m.material_grade || m.full_material_grade, - quantity: m.quantity, + quantity: m.quantity, // 이미 합산된 수량 unit: m.unit, user_requirement: userRequirements[m.id] || '' })) @@ -374,7 +445,7 @@ const SupportMaterialsView = ({ {/* 헤더 */}
{info.size}
{info.grade}
{info.userRequirements}
-
- setUserRequirements({ - ...userRequirements, - [consolidatedMaterial.id]: e.target.value - })} - placeholder="Enter additional request..." - style={{ - width: '100%', - padding: '8px', - border: '1px solid #d1d5db', - borderRadius: '4px', - fontSize: '12px' - }} - /> +
+ {!editingRequest[consolidatedMaterial.id] && savedRequests[consolidatedMaterial.id] ? ( + // 저장된 상태 - 요구사항 표시 + 수정 버튼 + <> +
+ {savedRequests[consolidatedMaterial.id]} +
+ + + ) : ( + // 편집 상태 - 입력 필드 + 저장 버튼 + <> + setUserRequirements({ + ...userRequirements, + [consolidatedMaterial.id]: e.target.value + })} + placeholder="Enter additional request..." + disabled={hasAnyPurchased} + style={{ + flex: 1, + padding: '8px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '12px', + opacity: hasAnyPurchased ? 0.5 : 1, + cursor: hasAnyPurchased ? 'not-allowed' : 'text' + }} + /> + + + )}
{info.purchaseQuantity}
diff --git a/frontend/src/components/bom/materials/ValveMaterialsView.jsx b/frontend/src/components/bom/materials/ValveMaterialsView.jsx index 699ba66..a4c61aa 100644 --- a/frontend/src/components/bom/materials/ValveMaterialsView.jsx +++ b/frontend/src/components/bom/materials/ValveMaterialsView.jsx @@ -11,6 +11,7 @@ const ValveMaterialsView = ({ setUserRequirements, purchasedMaterials, onPurchasedMaterialsUpdate, + updateMaterial, // 자재 업데이트 함수 fileId, jobNo, user @@ -18,58 +19,154 @@ const ValveMaterialsView = ({ const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); const [columnFilters, setColumnFilters] = useState({}); const [showFilterDropdown, setShowFilterDropdown] = useState(null); + const [brandInputs, setBrandInputs] = useState({}); // 브랜드 입력 상태 + const [savingBrand, setSavingBrand] = useState({}); // 브랜드 저장 중 상태 + const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태 + const [savedBrands, setSavedBrands] = useState({}); // 저장된 브랜드 상태 + const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태 + const [editingBrand, setEditingBrand] = useState({}); // 브랜드 편집 모드 + const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드 + + // 컴포넌트 마운트 시 저장된 데이터 로드 + React.useEffect(() => { + const loadSavedData = () => { + const savedBrandsData = {}; + const savedRequestsData = {}; + + console.log('🔍 ValveMaterialsView useEffect 트리거됨:', materials.length, '개 자재'); + console.log('🔍 현재 materials 배열:', materials.map(m => ({id: m.id, brand: m.brand, user_requirement: m.user_requirement}))); + + materials.forEach(material => { + // 백엔드에서 가져온 데이터가 있으면 저장된 상태로 설정 + if (material.brand && material.brand.trim()) { + savedBrandsData[material.id] = material.brand.trim(); + console.log('✅ 브랜드 로드됨:', material.id, '→', material.brand); + } + if (material.user_requirement && material.user_requirement.trim()) { + savedRequestsData[material.id] = material.user_requirement.trim(); + console.log('✅ 요구사항 로드됨:', material.id, '→', material.user_requirement); + } + }); + + console.log('💾 최종 저장된 브랜드:', savedBrandsData); + console.log('💾 최종 저장된 요구사항:', savedRequestsData); + + // 상태 업데이트를 즉시 반영하기 위해 setTimeout 사용 + setSavedBrands(savedBrandsData); + setSavedRequests(savedRequestsData); + + // 상태 업데이트 후 강제 리렌더링 확인 + setTimeout(() => { + console.log('🔄 상태 업데이트 후 확인 - savedBrands:', savedBrandsData); + }, 100); + }; + + console.log('🔄 ValveMaterialsView useEffect 실행 - materials 길이:', materials?.length || 0); + + if (materials && materials.length > 0) { + loadSavedData(); + } else { + console.log('⚠️ materials가 비어있거나 undefined'); + // 빈 상태로 초기화 + setSavedBrands({}); + setSavedRequests({}); + } + }, [materials]); const parseValveInfo = (material) => { const valveDetails = material.valve_details || {}; const description = material.original_description || ''; + const descUpper = description.toUpperCase(); - // 밸브 타입 파싱 (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'; + // 1. 벨브 타입 파싱 (한글명으로 표시) + let valveType = ''; + if (descUpper.includes('SIGHT GLASS') || descUpper.includes('사이트글라스')) { + valveType = 'SIGHT GLASS'; + } else if (descUpper.includes('STRAINER') || descUpper.includes('스트레이너')) { + valveType = 'STRAINER'; + } else if (descUpper.includes('GATE') || descUpper.includes('게이트')) { + valveType = 'GATE VALVE'; + } else if (descUpper.includes('BALL') || descUpper.includes('볼')) { + valveType = 'BALL VALVE'; + } else if (descUpper.includes('CHECK') || descUpper.includes('체크')) { + valveType = 'CHECK VALVE'; + } else if (descUpper.includes('GLOBE') || descUpper.includes('글로브')) { + valveType = 'GLOBE VALVE'; + } else if (descUpper.includes('BUTTERFLY') || descUpper.includes('버터플라이')) { + valveType = 'BUTTERFLY VALVE'; + } else if (descUpper.includes('NEEDLE') || descUpper.includes('니들')) { + valveType = 'NEEDLE VALVE'; + } else if (descUpper.includes('RELIEF') || descUpper.includes('릴리프')) { + valveType = 'RELIEF VALVE'; + } else { + valveType = 'VALVE'; } - // 연결 방식 파싱 (FLG, SW, THRD 등) - 기존 NewMaterialsPage와 동일 + // 2. 사이즈 정보 + const size = material.main_nom || material.size_inch || material.size_spec || '-'; + + // 3. 압력 등급 + const pressure = material.pressure_rating || + (descUpper.match(/(\d+)\s*LB/) ? descUpper.match(/(\d+)\s*LB/)[0] : '-'); + + // 4. 브랜드 (사용자 입력 가능) + const brand = '-'; // 기본값, 사용자가 입력할 수 있도록 + + // 5. 추가 정보 추출 (3-WAY, DOUL PLATE, DOUBLE DISC 등) + let additionalInfo = ''; + const additionalPatterns = [ + '3-WAY', '3WAY', 'THREE WAY', + 'DOUL PLATE', 'DOUBLE PLATE', 'DUAL PLATE', + 'DOUBLE DISC', 'DUAL DISC', + 'SWING', 'LIFT', 'TILTING', + 'WAFER', 'LUG', 'FLANGED', + 'FULL BORE', 'REDUCED BORE', + 'FIRE SAFE', 'ANTI STATIC' + ]; + + for (const pattern of additionalPatterns) { + if (descUpper.includes(pattern)) { + if (additionalInfo) { + additionalInfo += ', '; + } + additionalInfo += pattern; + } + } + + if (!additionalInfo) { + additionalInfo = '-'; + } + + // 6. 연결 방식 (투입구/Connection Type) let connectionType = ''; - if (description.includes('FLG')) { - connectionType = 'FLG'; - } else if (description.includes('SW X THRD')) { + if (descUpper.includes('SW X THRD') || descUpper.includes('SW×THRD')) { connectionType = 'SW×THRD'; - } else if (description.includes('SW')) { + } else if (descUpper.includes('FLG') || descUpper.includes('FLANGE')) { + connectionType = 'FLG'; + } else if (descUpper.includes('SW') || descUpper.includes('SOCKET')) { connectionType = 'SW'; - } else if (description.includes('THRD')) { + } else if (descUpper.includes('THRD') || descUpper.includes('THREAD')) { connectionType = 'THRD'; - } else if (description.includes('BW')) { + } else if (descUpper.includes('BW') || descUpper.includes('BUTT WELD')) { connectionType = 'BW'; + } else { + connectionType = '-'; } - // 압력 등급 파싱 - let pressure = '-'; - const pressureMatch = description.match(/(\d+)LB/i); - if (pressureMatch) { - pressure = `${pressureMatch[1]}LB`; - } - - // 스케줄은 밸브에는 일반적으로 없음 (기본값) - let schedule = '-'; + // 7. 구매 수량 계산 (기본 수량 그대로) + const qty = Math.round(material.quantity || 0); + const purchaseQuantity = `${qty} EA`; return { - type: 'VALVE', - subtype: `${valveType} ${connectionType}`.trim() || 'VALVE', // 타입과 연결방식 결합 - valveType: valveType, - connectionType: connectionType, - size: material.size_spec || '-', + type: valveType, // 벨브 종류만 (GATE VALVE, BALL VALVE 등) + size: size, pressure: pressure, - schedule: schedule, - grade: material.material_grade || '-', - quantity: Math.round(material.quantity || 0), - unit: '개', + brand: brand, // 브랜드 (사용자 입력 가능) + additionalInfo: additionalInfo, // 추가 정보 (3-WAY, DOUL PLATE 등) + connection: connectionType, // 투입구/연결방식 (SW, FLG 등) + additionalRequest: '-', // 추가 요구사항 (기존 User Requirement) + purchaseQuantity: purchaseQuantity, + originalQuantity: qty, isValve: true }; }; @@ -130,6 +227,68 @@ const ValveMaterialsView = ({ }; // 전체 선택/해제 (구매신청된 자재 제외) + // 브랜드 저장 함수 + const handleSaveBrand = async (materialId, brand) => { + if (!brand.trim()) return; + + setSavingBrand(prev => ({ ...prev, [materialId]: true })); + try { + await api.patch(`/materials/${materialId}/brand`, { brand: brand.trim() }); + // 성공 시 저장된 상태로 전환 + setSavedBrands(prev => ({ ...prev, [materialId]: brand.trim() })); + setEditingBrand(prev => ({ ...prev, [materialId]: false })); + setBrandInputs(prev => ({ ...prev, [materialId]: '' })); // 입력 필드 초기화 + + // materials 배열도 업데이트 (카테고리 변경 시 데이터 유지를 위해) + if (updateMaterial) { + updateMaterial(materialId, { brand: brand.trim() }); + console.log('✅ materials 배열 업데이트 완료:', materialId, '→', brand.trim()); + } + } catch (error) { + console.error('브랜드 저장 실패:', error); + alert('브랜드 저장에 실패했습니다.'); + } finally { + setSavingBrand(prev => ({ ...prev, [materialId]: false })); + } + }; + + // 브랜드 편집 시작 + const handleEditBrand = (materialId, currentBrand) => { + setEditingBrand(prev => ({ ...prev, [materialId]: true })); + setBrandInputs(prev => ({ ...prev, [materialId]: currentBrand || '' })); + }; + + // 추가요구사항 저장 함수 + 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]: '' })); // 입력 필드 초기화 + + // materials 배열도 업데이트 (카테고리 변경 시 데이터 유지를 위해) + if (updateMaterial) { + updateMaterial(materialId, { user_requirement: request.trim() }); + console.log('✅ materials 배열 업데이트 완료:', materialId, '→', 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 handleSelectAll = () => { const filteredMaterials = getFilteredAndSortedMaterials(); const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id)); @@ -169,7 +328,7 @@ const ValveMaterialsView = ({ const dataWithRequirements = selectedMaterialsData.map(material => ({ ...material, - user_requirement: userRequirements[material.id] || '' + user_requirement: savedRequests[material.id] || userRequirements[material.id] || '' })); try { @@ -289,45 +448,48 @@ const ValveMaterialsView = ({ > {selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'} - - -
+ +
+
{/* 테이블 */}
- {/* 헤더 */} -
+
+ {/* 헤더 */} +
- Type - Size - Pressure - Schedule - Material Grade - Quantity -
Unit
-
User Requirement
+ + Type + + + Size + + + Pressure + +
Brand
+ + Additional Info + + + Connection + +
Additional Request
+ + Purchase Quantity +
{/* 데이터 행들 */} -
- {filteredMaterials.map((material, index) => { + {filteredMaterials.map((material, index) => { const info = parseValveInfo(material); const isSelected = selectedMaterials.has(material.id); const isPurchased = purchasedMaterials.has(material.id); @@ -360,89 +587,230 @@ const ValveMaterialsView = ({
{ - if (!isSelected && !isPurchased) { - e.target.style.background = '#f8fafc'; + 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'; + } + }} + > +
+ handleMaterialSelect(material.id)} + disabled={isPurchased} + style={{ + cursor: isPurchased ? 'not-allowed' : 'pointer', + opacity: isPurchased ? 0.5 : 1 + }} + /> +
+
+ {info.type} + {isPurchased && ( + + PURCHASED + + )} +
+
{info.size}
+
{info.pressure}
+
+ {(() => { + // 디버깅: 렌더링 시점의 상태 확인 + const hasEditingBrand = !!editingBrand[material.id]; + const hasSavedBrand = !!savedBrands[material.id]; + const shouldShowSaved = !hasEditingBrand && hasSavedBrand; + + if (material.id === 11789) { // 테스트 자재만 로그 + console.log(`🎨 UI 렌더링 - ID ${material.id}:`, { + editingBrand: hasEditingBrand, + savedBrandExists: hasSavedBrand, + savedBrandValue: savedBrands[material.id], + shouldShowSaved: shouldShowSaved, + allSavedBrands: Object.keys(savedBrands), + renderingMode: shouldShowSaved ? 'SAVED_VIEW' : 'INPUT_VIEW' + }); } - }} - 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', + {savedBrands[material.id]} +
+ + + ) : ( + // 편집 상태 - 입력 필드 + 저장 버튼 + <> + setBrandInputs({ + ...brandInputs, + [material.id]: e.target.value + })} + placeholder="Enter brand..." + disabled={isPurchased} + style={{ + flex: 1, + padding: '6px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '11px', + textAlign: 'center', + opacity: isPurchased ? 0.5 : 1, + cursor: isPurchased ? 'not-allowed' : 'text' + }} + /> + + + )} +
+
{info.additionalInfo}
+
{info.connection}
+
+ {!editingRequest[material.id] && savedRequests[material.id] ? ( + // 저장된 상태 - 요구사항 표시 + 수정 버튼 + <> +
-
+ fontSize: '11px', + background: '#f9fafb', + color: '#374151' + }}> + {savedRequests[material.id]} +
+ + + ) : ( + // 편집 상태 - 입력 필드 + 저장 버튼 + <> + setUserRequirements({ + ...userRequirements, + [material.id]: e.target.value + })} + placeholder="Enter additional request..." + disabled={isPurchased} + style={{ + flex: 1, + padding: '6px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '11px', + opacity: isPurchased ? 0.5 : 1, + cursor: isPurchased ? 'not-allowed' : 'text' + }} + /> + + + )} +
+
{info.purchaseQuantity}
); })} diff --git a/frontend/src/pages/BOMManagementPage.jsx b/frontend/src/pages/BOMManagementPage.jsx index 3c0d274..3e30a15 100644 --- a/frontend/src/pages/BOMManagementPage.jsx +++ b/frontend/src/pages/BOMManagementPage.jsx @@ -33,6 +33,17 @@ const BOMManagementPage = ({ const [purchasedMaterials, setPurchasedMaterials] = useState(new Set()); const [error, setError] = useState(null); + // 자재 업데이트 함수 (브랜드, 사용자 요구사항 등) + const updateMaterial = (materialId, updates) => { + setMaterials(prevMaterials => + prevMaterials.map(material => + material.id === materialId + ? { ...material, ...updates } + : material + ) + ); + }; + // 카테고리 정의 const categories = [ { key: 'PIPE', label: 'Pipes', color: '#3b82f6' }, @@ -175,6 +186,7 @@ const BOMManagementPage = ({ return newSet; }); }, + updateMaterial, // 자재 업데이트 함수 추가 fileId, jobNo, user, diff --git a/frontend/src/utils/excelExport.js b/frontend/src/utils/excelExport.js index fd9aac5..30b4de3 100644 --- a/frontend/src/utils/excelExport.js +++ b/frontend/src/utils/excelExport.js @@ -115,6 +115,56 @@ const consolidateMaterials = (materials, isComparison = false) => { return Object.values(consolidated); }; +/** + * 벨브 연결방식 추출 함수 + */ +const extractValveConnectionType = (description) => { + const descUpper = description.toUpperCase(); + + if (descUpper.includes('SW X THRD') || descUpper.includes('SW×THRD')) { + return 'SW×THRD'; + } else if (descUpper.includes('FLG') || descUpper.includes('FLANGE')) { + return 'FLG'; + } else if (descUpper.includes('SW') || descUpper.includes('SOCKET')) { + return 'SW'; + } else if (descUpper.includes('THRD') || descUpper.includes('THREAD')) { + return 'THRD'; + } else if (descUpper.includes('BW') || descUpper.includes('BUTT WELD')) { + return 'BW'; + } else { + return '-'; + } +}; + +/** + * 벨브 추가 정보 추출 함수 + */ +const extractValveAdditionalInfo = (description) => { + const descUpper = description.toUpperCase(); + let additionalInfo = ''; + + const additionalPatterns = [ + '3-WAY', '3WAY', 'THREE WAY', + 'DOUL PLATE', 'DOUBLE PLATE', 'DUAL PLATE', + 'DOUBLE DISC', 'DUAL DISC', + 'SWING', 'LIFT', 'TILTING', + 'WAFER', 'LUG', 'FLANGED', + 'FULL BORE', 'REDUCED BORE', + 'FIRE SAFE', 'ANTI STATIC' + ]; + + for (const pattern of additionalPatterns) { + if (descUpper.includes(pattern)) { + if (additionalInfo) { + additionalInfo += ', '; + } + additionalInfo += pattern; + } + } + + return additionalInfo || '-'; +}; + /** * 자재 데이터를 엑셀용 형태로 변환 */ @@ -401,24 +451,28 @@ const formatMaterialForExcel = (material, includeComparison = false) => { } else if (valveType === 'RELIEF') { itemName = '릴리프 밸브'; } else { - // description에서 추출 + // description에서 추출 (BOM 페이지와 동일한 로직) const desc = cleanDescription.toUpperCase(); - if (desc.includes('GATE')) { - itemName = '게이트 밸브'; - } else if (desc.includes('BALL')) { - itemName = '볼 밸브'; - } else if (desc.includes('GLOBE')) { - itemName = '글로브 밸브'; - } else if (desc.includes('CHECK')) { - itemName = '체크 밸브'; - } else if (desc.includes('BUTTERFLY')) { - itemName = '버터플라이 밸브'; - } else if (desc.includes('NEEDLE')) { - itemName = '니들 밸브'; - } else if (desc.includes('RELIEF')) { - itemName = '릴리프 밸브'; + if (desc.includes('SIGHT GLASS') || desc.includes('사이트글라스')) { + itemName = 'SIGHT GLASS'; + } else if (desc.includes('STRAINER') || desc.includes('스트레이너')) { + itemName = 'STRAINER'; + } else if (desc.includes('GATE') || desc.includes('게이트')) { + itemName = 'GATE VALVE'; + } else if (desc.includes('BALL') || desc.includes('볼')) { + itemName = 'BALL VALVE'; + } else if (desc.includes('CHECK') || desc.includes('체크')) { + itemName = 'CHECK VALVE'; + } else if (desc.includes('GLOBE') || desc.includes('글로브')) { + itemName = 'GLOBE VALVE'; + } else if (desc.includes('BUTTERFLY') || desc.includes('버터플라이')) { + itemName = 'BUTTERFLY VALVE'; + } else if (desc.includes('NEEDLE') || desc.includes('니들')) { + itemName = 'NEEDLE VALVE'; + } else if (desc.includes('RELIEF') || desc.includes('릴리프')) { + itemName = 'RELIEF VALVE'; } else { - itemName = '밸브'; + itemName = 'VALVE'; } } } else if (category === 'GASKET') { @@ -489,13 +543,15 @@ const formatMaterialForExcel = (material, includeComparison = false) => { const desc = cleanDescription.toUpperCase(); if (desc.includes('URETHANE') || desc.includes('BLOCK SHOE')) { itemName = 'URETHANE BLOCK SHOE'; - // 우레탄 블럭슈의 경우 두께 정보 추가 - const thicknessMatch = desc.match(/(\d+)\s*[tT](?![oO])/); - if (thicknessMatch) { - itemName += ` ${thicknessMatch[1]}t`; - } + // 우레탄 블럭슈의 경우 두께 정보는 품목명에 포함하지 않음 (재질 열에서 처리) } else if (desc.includes('CLAMP')) { - itemName = 'CLAMP'; + // 클램프 타입 상세 분류 (CL-1, CL-2, CL-3 등) + const clampMatch = desc.match(/CL[-\s]*(\d+)/i); + if (clampMatch) { + itemName = `CLAMP CL-${clampMatch[1]}`; + } else { + itemName = 'CLAMP CL-1'; // 기본값 + } } else if (desc.includes('U-BOLT') || desc.includes('U BOLT')) { itemName = 'U-BOLT'; } else if (desc.includes('HANGER')) { @@ -823,17 +879,17 @@ const formatMaterialForExcel = (material, includeComparison = false) => { base['관리항목3'] = ''; // N열 base['관리항목4'] = ''; // O열 } else if (category === 'VALVE') { - // 밸브 전용 컬럼 (F~O) - 타입 제거, 품목명에 포함됨 - base['크기'] = material.size_spec || '-'; // F열 + // 밸브 전용 컬럼 (F~O) - 새로운 구조 + base['크기'] = material.size_spec || material.main_nom || '-'; // F열 base['압력등급'] = pressure; // G열 - base['재질'] = grade; // H열 - base['상세내역'] = detailInfo || '-'; // I열 - base['사용자요구'] = material.user_requirement || ''; // J열 - base['관리항목1'] = ''; // K열 - base['관리항목2'] = ''; // L열 - base['관리항목3'] = ''; // M열 - base['관리항목4'] = ''; // N열 - base['관리항목5'] = ''; // O열 + base['브랜드'] = material.brand || '-'; // H열 (사용자 입력) + base['추가정보'] = extractValveAdditionalInfo(cleanDescription); // I열 (3-WAY, DOUL PLATE 등) + base['연결방식'] = material.connection_type || extractValveConnectionType(cleanDescription); // J열 + base['추가요청사항'] = material.user_requirement || ''; // K열 + base['관리항목1'] = ''; // L열 + base['관리항목2'] = ''; // M열 + base['관리항목3'] = ''; // N열 + base['관리항목4'] = ''; // O열 } else if (category === 'GASKET') { // 가스켓 전용 컬럼 (F~O) - 재질을 2개로 분리 // 재질 분리 로직: SS304/GRAPHITE/SS304/SS304 → 재질1: SS304/GRAPHITE, 재질2: /SS304/SS304 @@ -866,10 +922,10 @@ const formatMaterialForExcel = (material, includeComparison = false) => { base['재질1'] = material1; // I열: SS304/GRAPHITE base['재질2'] = material2; // J열: SS304/SS304 base['두께'] = gasketThickness || '-'; // K열: 4.5mm - base['사용자요구'] = material.user_requirement || ''; // L열 - base['관리항목1'] = ''; // M열 - base['관리항목2'] = ''; // N열 - base['관리항목3'] = ''; // O열 + base['사용자요구'] = detailInfo; // L열: 분류기에서 추출된 요구사항 + base['추가요청사항'] = material.user_requirement || ''; // M열: 사용자 입력 요구사항 + base['관리항목1'] = ''; // N열 + base['관리항목2'] = ''; // O열 } else if (category === 'BOLT') { // 볼트 전용 컬럼 (F~O) - User Requirements와 Additional Request 분리 base['크기'] = material.size_spec || '-'; // F열 @@ -883,17 +939,17 @@ const formatMaterialForExcel = (material, includeComparison = false) => { base['관리항목3'] = ''; // N열 base['관리항목4'] = ''; // O열 } else if (category === 'SUPPORT') { - // 서포트 전용 컬럼 (F~O) + // 서포트 전용 컬럼 (F~O) - 압력등급, 상세내역 제거 base['크기'] = material.size_spec || material.main_nom || '-'; // F열 - base['압력등급'] = pressure; // G열 - base['재질'] = grade; // H열 - base['상세내역'] = material.original_description || '-'; // I열 - base['사용자요구'] = material.user_requirements?.join(', ') || ''; // J열 (분류기에서 추출) - base['추가요청사항'] = material.user_requirement || ''; // K열 (사용자 입력) - base['관리항목1'] = ''; // L열 - base['관리항목2'] = ''; // M열 - base['관리항목3'] = ''; // N열 - base['관리항목4'] = ''; // O열 + base['재질'] = grade; // G열 + base['사용자요구'] = material.user_requirements?.join(', ') || ''; // 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 { // 기타 카테고리 기본 컬럼 (F~O) - 타입 제거, 품목명에 포함됨 base['크기'] = material.size_spec || '-'; // F열 @@ -1153,10 +1209,10 @@ export const createExcelBlob = async (materials, filename, options = {}) => { 'PIPE': ['크기', '스케줄', '재질', '제조방법', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], 'FITTING': ['크기', '압력등급', '스케줄', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], 'FLANGE': ['크기', '페이싱', '압력등급', '스케줄', '재질', '추가요구사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], - 'VALVE': ['크기', '압력등급', '재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], - 'GASKET': ['크기', '압력등급', '구조', '재질1', '재질2', '두께', '사용자요구', '관리항목1', '관리항목2', '관리항목3'], + 'VALVE': ['크기', '압력등급', '브랜드', '추가정보', '연결방식', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], + 'GASKET': ['크기', '압력등급', '구조', '재질1', '재질2', '두께', '사용자요구', '추가요청사항', '관리항목1', '관리항목2'], 'BOLT': ['크기', '압력등급', '길이', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], - 'SUPPORT': ['크기', '압력등급', '재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], + 'SUPPORT': ['크기', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4', '관리항목5', '관리항목6'], }; const deliveryDateHeader = '납기일(YYYY-MM-DD)';