feat: 모든 카테고리에 추가요청사항 저장 기능 및 엑셀 내보내기 개선
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:
hyungi
2025-10-17 12:54:17 +09:00
parent 6b6360ecd5
commit f336b5a4a8
12 changed files with 1667 additions and 333 deletions

View File

@@ -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

View File

@@ -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 결과 사용)

View 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)}"
)

View File

@@ -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}

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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()}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>
);
})}

View File

@@ -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,

View File

@@ -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)';