feat: 모든 카테고리에 추가요청사항 저장 기능 및 엑셀 내보내기 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 모든 BOM 카테고리(Pipe, Fitting, Flange, Gasket, Bolt, Support)에 추가요청사항 저장/편집 기능 추가
- 저장된 데이터의 카테고리 변경 및 페이지 새로고침 시 지속성 보장
- 백엔드 materials 테이블에 brand, user_requirement 컬럼 추가
- 새로운 /materials/{id}/brand, /materials/{id}/user-requirement PATCH API 엔드포인트 추가
- 모든 카테고리에서 Additional Request 컬럼 너비 확장 (UI 겹침 방지)
- GASKET 카테고리 엑셀 내보내기에 누락된 '추가요청사항' 컬럼 추가
- 엑셀 내보내기 시 저장된 추가요청사항이 우선 반영되도록 개선
- P열 납기일 규칙 유지하며 관리항목 개수 조정
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 결과 사용)
|
||||
|
||||
161
backend/app/routers/materials.py
Normal file
161
backend/app/routers/materials.py
Normal file
@@ -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)}"
|
||||
)
|
||||
@@ -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 = ({
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 150px 200px',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 200px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
@@ -515,7 +569,7 @@ const BoltMaterialsView = ({
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 150px 200px',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 200px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
@@ -580,23 +634,81 @@ const BoltMaterialsView = ({
|
||||
}}>
|
||||
{info.userRequirements}
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => 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'
|
||||
}}
|
||||
/>
|
||||
<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}
|
||||
|
||||
@@ -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 = ({
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 120px 200px',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 120px 250px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
@@ -669,7 +722,7 @@ const FittingMaterialsView = ({
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 120px 200px',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 120px 250px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
@@ -740,23 +793,81 @@ const FittingMaterialsView = ({
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
|
||||
{info.quantity} {info.unit}
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => 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'
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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 = ({
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 250px 150px 120px 120px 100px 150px 180px 200px',
|
||||
gridTemplateColumns: '50px 250px 150px 120px 120px 100px 150px 180px 250px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
@@ -499,7 +552,7 @@ const FlangeMaterialsView = ({
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 250px 150px 120px 120px 100px 150px 180px 200px',
|
||||
gridTemplateColumns: '50px 250px 150px 120px 120px 100px 150px 180px 250px',
|
||||
gap: '12px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
@@ -563,23 +616,81 @@ const FlangeMaterialsView = ({
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'center' }}>
|
||||
{info.quantity} {info.unit}
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => 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'
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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 = ({
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 250px 120px 100px 120px 200px 100px 180px 150px 120px',
|
||||
gridTemplateColumns: '50px 250px 120px 100px 120px 200px 100px 180px 200px 120px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
@@ -461,7 +514,7 @@ const GasketMaterialsView = ({
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 250px 120px 100px 120px 200px 100px 180px 150px 120px',
|
||||
gridTemplateColumns: '50px 250px 120px 100px 120px 200px 100px 180px 200px 120px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
@@ -532,23 +585,81 @@ const GasketMaterialsView = ({
|
||||
}}>
|
||||
{info.userRequirements}
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => 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'
|
||||
}}
|
||||
/>
|
||||
<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', fontWeight: '500' }}>
|
||||
{info.purchaseQuantity.toLocaleString()}
|
||||
|
||||
@@ -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 = ({
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 120px 120px 120px 150px 200px 120px 100px 120px 250px',
|
||||
gridTemplateColumns: '50px 120px 120px 120px 150px 200px 120px 100px 120px 300px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
@@ -519,7 +612,7 @@ const PipeMaterialsView = ({
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 120px 120px 120px 150px 200px 120px 100px 120px 250px',
|
||||
gridTemplateColumns: '50px 120px 120px 120px 150px 200px 120px 100px 120px 300px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
@@ -593,23 +686,81 @@ const PipeMaterialsView = ({
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>
|
||||
{info.unit}
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => 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'
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
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',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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 = ({
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 150px 180px 150px 200px',
|
||||
gridTemplateColumns: '50px 200px 120px 150px 180px 200px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
@@ -460,7 +531,7 @@ const SupportMaterialsView = ({
|
||||
key={`consolidated-${index}`}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 150px 180px 150px 200px',
|
||||
gridTemplateColumns: '50px 200px 120px 150px 180px 200px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
@@ -510,23 +581,81 @@ const SupportMaterialsView = ({
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.size}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.grade}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.userRequirements}</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[consolidatedMaterial.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[consolidatedMaterial.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[consolidatedMaterial.id] && savedRequests[consolidatedMaterial.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{savedRequests[consolidatedMaterial.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditRequest(consolidatedMaterial.id, savedRequests[consolidatedMaterial.id])}
|
||||
disabled={hasAnyPurchased}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: hasAnyPurchased ? '#d1d5db' : '#f59e0b',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: hasAnyPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: hasAnyPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[consolidatedMaterial.id] || ''}
|
||||
onChange={(e) => 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'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRequest(consolidatedMaterial.id, userRequirements[consolidatedMaterial.id] || '')}
|
||||
disabled={hasAnyPurchased || savingRequest[consolidatedMaterial.id]}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: hasAnyPurchased ? '#d1d5db' : '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: hasAnyPurchased || savingRequest[consolidatedMaterial.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: hasAnyPurchased ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingRequest[consolidatedMaterial.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.purchaseQuantity}</div>
|
||||
</div>
|
||||
|
||||
@@ -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'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' : '#e5e7eb',
|
||||
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Export to Excel ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
disabled={selectedMaterials.size === 0}
|
||||
style={{
|
||||
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' : '#e5e7eb',
|
||||
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px',
|
||||
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Export to Excel ({selectedMaterials.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
maxHeight: '600px'
|
||||
}}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151'
|
||||
}}>
|
||||
<div style={{ minWidth: '1600px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 120px 150px 180px 180px 150px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -339,19 +501,84 @@ const ValveMaterialsView = ({
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader sortKey="subtype" filterKey="subtype">Type</FilterableHeader>
|
||||
<FilterableHeader sortKey="size" filterKey="size">Size</FilterableHeader>
|
||||
<FilterableHeader sortKey="pressure" filterKey="pressure">Pressure</FilterableHeader>
|
||||
<FilterableHeader sortKey="schedule" filterKey="schedule">Schedule</FilterableHeader>
|
||||
<FilterableHeader sortKey="grade" filterKey="grade">Material Grade</FilterableHeader>
|
||||
<FilterableHeader sortKey="quantity" filterKey="quantity">Quantity</FilterableHeader>
|
||||
<div>Unit</div>
|
||||
<div>User Requirement</div>
|
||||
<FilterableHeader
|
||||
sortKey="type"
|
||||
filterKey="type"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Type
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="size"
|
||||
filterKey="size"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Size
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="pressure"
|
||||
filterKey="pressure"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Pressure
|
||||
</FilterableHeader>
|
||||
<div>Brand</div>
|
||||
<FilterableHeader
|
||||
sortKey="additionalInfo"
|
||||
filterKey="additionalInfo"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Additional Info
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="connection"
|
||||
filterKey="connection"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Connection
|
||||
</FilterableHeader>
|
||||
<div>Additional Request</div>
|
||||
<FilterableHeader
|
||||
sortKey="purchaseQuantity"
|
||||
filterKey="purchaseQuantity"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Purchase Quantity
|
||||
</FilterableHeader>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
<div style={{ maxHeight: '600px', overflowY: 'auto' }}>
|
||||
{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 = ({
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
|
||||
gap: '16px',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 120px 150px 180px 180px 150px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
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';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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: '500'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.size}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.pressure}</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{(() => {
|
||||
// 디버깅: 렌더링 시점의 상태 확인
|
||||
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';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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.subtype}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
|
||||
// 명시적으로 boolean 반환
|
||||
return shouldShowSaved ? true : false;
|
||||
})() ? (
|
||||
// 저장된 상태 - 브랜드 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500'
|
||||
fontSize: '11px',
|
||||
textAlign: 'center',
|
||||
background: '#f9fafb',
|
||||
color: '#374151'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.size}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.pressure}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.schedule}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.grade}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
|
||||
{info.quantity}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280' }}>
|
||||
{info.unit}
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter requirement..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
{savedBrands[material.id]}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEditBrand(material.id, savedBrands[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={brandInputs[material.id] || ''}
|
||||
onChange={(e) => 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'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveBrand(material.id, brandInputs[material.id] || '')}
|
||||
disabled={isPurchased || savingBrand[material.id] || !brandInputs[material.id]?.trim()}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: isPurchased ? '#d1d5db' : '#3b82f6',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
cursor: isPurchased || savingBrand[material.id] ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased || !brandInputs[material.id]?.trim() ? 0.5 : 1,
|
||||
minWidth: '40px'
|
||||
}}
|
||||
>
|
||||
{savingBrand[material.id] ? '...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.additionalInfo}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.connection}</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
fontSize: '11px',
|
||||
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',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
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' }}>{info.purchaseQuantity}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)';
|
||||
|
||||
Reference in New Issue
Block a user