feat: 가스켓 카테고리 개선 및 엑셀 내보내기 최적화
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 가스켓 카테고리 정렬 오류 수정 (FilterableHeader props 추가) - 가스켓 엑셀 내보내기 개선: * 품목명을 BOM 페이지 타입과 동일하게 표시 (SPIRAL WOUND GASKET 등) * 재질을 재질1/재질2로 분리 (SS304/GRAPHITE → 재질1: SS304/GRAPHITE, 재질2: /SS304/SS304) * originalDescription에서 4개 재질 패턴 우선 추출 * P열 납기일 규칙 준수 - 프로젝트 비활성화 기능 수정 (localStorage 영구 저장) - 모든 카테고리 정렬 함수 안전성 강화
This commit is contained in:
@@ -34,7 +34,25 @@ function App() {
|
|||||||
const [newProjectName, setNewProjectName] = useState('');
|
const [newProjectName, setNewProjectName] = useState('');
|
||||||
const [newClientName, setNewClientName] = useState('');
|
const [newClientName, setNewClientName] = useState('');
|
||||||
const [pendingSignupCount, setPendingSignupCount] = useState(0);
|
const [pendingSignupCount, setPendingSignupCount] = useState(0);
|
||||||
const [inactiveProjects, setInactiveProjects] = useState(new Set());
|
const [inactiveProjects, setInactiveProjects] = useState(() => {
|
||||||
|
// localStorage에서 비활성화된 프로젝트 목록 로드
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('inactiveProjects');
|
||||||
|
return saved ? new Set(JSON.parse(saved)) : new Set();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('비활성화 프로젝트 목록 로드 실패:', error);
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 비활성화 프로젝트 목록이 변경될 때마다 localStorage에 저장
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('inactiveProjects', JSON.stringify(Array.from(inactiveProjects)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('비활성화 프로젝트 목록 저장 실패:', error);
|
||||||
|
}
|
||||||
|
}, [inactiveProjects]);
|
||||||
|
|
||||||
// 승인 대기 중인 회원가입 수 조회
|
// 승인 대기 중인 회원가입 수 조회
|
||||||
const loadPendingSignups = async () => {
|
const loadPendingSignups = async () => {
|
||||||
|
|||||||
@@ -149,17 +149,34 @@ const BoltMaterialsView = ({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sortConfig.key) {
|
if (sortConfig && sortConfig.key) {
|
||||||
filtered.sort((a, b) => {
|
filtered.sort((a, b) => {
|
||||||
const aInfo = parseBoltInfo(a);
|
const aInfo = parseBoltInfo(a);
|
||||||
const bInfo = parseBoltInfo(b);
|
const bInfo = parseBoltInfo(b);
|
||||||
const aValue = aInfo[sortConfig.key] || '';
|
|
||||||
const bValue = bInfo[sortConfig.key] || '';
|
if (!aInfo || !bInfo) return 0;
|
||||||
|
|
||||||
|
const aValue = aInfo[sortConfig.key];
|
||||||
|
const bValue = bInfo[sortConfig.key];
|
||||||
|
|
||||||
|
// 값이 없는 경우 처리
|
||||||
|
if (aValue === undefined && bValue === undefined) return 0;
|
||||||
|
if (aValue === undefined) return 1;
|
||||||
|
if (bValue === undefined) return -1;
|
||||||
|
|
||||||
|
// 숫자인 경우 숫자로 비교
|
||||||
|
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||||
|
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문자열로 비교
|
||||||
|
const aStr = String(aValue).toLowerCase();
|
||||||
|
const bStr = String(bValue).toLowerCase();
|
||||||
|
|
||||||
if (sortConfig.direction === 'asc') {
|
if (sortConfig.direction === 'asc') {
|
||||||
return aValue > bValue ? 1 : -1;
|
return aStr.localeCompare(bStr);
|
||||||
} else {
|
} else {
|
||||||
return aValue < bValue ? 1 : -1;
|
return bStr.localeCompare(aStr);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ const GasketMaterialsView = ({
|
|||||||
userRequirements,
|
userRequirements,
|
||||||
setUserRequirements,
|
setUserRequirements,
|
||||||
purchasedMaterials,
|
purchasedMaterials,
|
||||||
|
onPurchasedMaterialsUpdate,
|
||||||
fileId,
|
fileId,
|
||||||
|
jobNo,
|
||||||
user
|
user
|
||||||
}) => {
|
}) => {
|
||||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||||
@@ -21,27 +23,57 @@ const GasketMaterialsView = ({
|
|||||||
const qty = Math.round(material.quantity || 0);
|
const qty = Math.round(material.quantity || 0);
|
||||||
const purchaseQty = Math.ceil(qty * 1.05 / 5) * 5; // 5% 여유율 + 5의 배수
|
const purchaseQty = Math.ceil(qty * 1.05 / 5) * 5; // 5% 여유율 + 5의 배수
|
||||||
|
|
||||||
// original_description에서 재질 정보 파싱 (기존 NewMaterialsPage와 동일)
|
|
||||||
const description = material.original_description || '';
|
const description = material.original_description || '';
|
||||||
let materialStructure = '-'; // H/F/I/O 부분
|
|
||||||
let materialDetail = '-'; // SS304/GRAPHITE/CS/CS 부분
|
|
||||||
|
|
||||||
// H/F/I/O와 재질 상세 정보 추출
|
// 가스켓 타입 풀네임 매핑
|
||||||
const materialMatch = description.match(/H\/F\/I\/O\s+(.+?)(?:,|$)/);
|
const gasketTypeMap = {
|
||||||
if (materialMatch) {
|
'SWG': 'SPIRAL WOUND GASKET',
|
||||||
materialStructure = 'H/F/I/O';
|
'RTJ': 'RING TYPE JOINT',
|
||||||
materialDetail = materialMatch[1].trim();
|
'FF': 'FULL FACE GASKET',
|
||||||
// 두께 정보 제거 (별도 추출)
|
'RF': 'RAISED FACE GASKET',
|
||||||
materialDetail = materialDetail.replace(/,?\s*\d+(?:\.\d+)?mm$/, '').trim();
|
'SHEET': 'SHEET GASKET',
|
||||||
|
'O-RING': 'O-RING GASKET'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 타입 추출 및 풀네임 변환
|
||||||
|
let gasketType = '-';
|
||||||
|
const typeMatch = description.match(/\b(SWG|RTJ|FF|RF|SHEET|O-RING)\b/i);
|
||||||
|
if (typeMatch) {
|
||||||
|
const shortType = typeMatch[1].toUpperCase();
|
||||||
|
gasketType = gasketTypeMap[shortType] || shortType;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 압력 정보 추출
|
// 크기 정보 추출 (예: 1 1/2")
|
||||||
|
let size = material.size_spec || material.size_inch || '-';
|
||||||
|
if (size === '-') {
|
||||||
|
const sizeMatch = description.match(/(\d+(?:\s+\d+\/\d+)?(?:\.\d+)?)\s*"/);
|
||||||
|
if (sizeMatch) {
|
||||||
|
size = sizeMatch[1] + '"';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 압력등급 추출
|
||||||
let pressure = '-';
|
let pressure = '-';
|
||||||
const pressureMatch = description.match(/(\d+LB)/);
|
const pressureMatch = description.match(/(\d+LB)/i);
|
||||||
if (pressureMatch) {
|
if (pressureMatch) {
|
||||||
pressure = pressureMatch[1];
|
pressure = pressureMatch[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 구조 정보 추출 (H/F/I/O)
|
||||||
|
let structure = '-';
|
||||||
|
if (description.includes('H/F/I/O')) {
|
||||||
|
structure = 'H/F/I/O';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 재질 상세 정보 추출 (SS304/GRAPHITE/SS304/SS304)
|
||||||
|
let material_detail = '-';
|
||||||
|
const materialMatch = description.match(/H\/F\/I\/O\s+([^,]+)/);
|
||||||
|
if (materialMatch) {
|
||||||
|
material_detail = materialMatch[1].trim();
|
||||||
|
// 두께 정보 제거
|
||||||
|
material_detail = material_detail.replace(/,?\s*\d+(?:\.\d+)?mm$/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
// 두께 정보 추출
|
// 두께 정보 추출
|
||||||
let thickness = '-';
|
let thickness = '-';
|
||||||
const thicknessMatch = description.match(/(\d+(?:\.\d+)?)\s*mm/i);
|
const thicknessMatch = description.match(/(\d+(?:\.\d+)?)\s*mm/i);
|
||||||
@@ -50,17 +82,14 @@ const GasketMaterialsView = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'GASKET',
|
type: gasketType, // 풀네임으로 표시 (SPIRAL WOUND GASKET)
|
||||||
subtype: 'SWG', // 항상 SWG로 표시
|
size: size,
|
||||||
size: material.size_spec || '-',
|
|
||||||
pressure: pressure,
|
pressure: pressure,
|
||||||
schedule: thickness, // 두께를 schedule 열에 표시
|
structure: structure, // H/F/I/O
|
||||||
materialStructure: materialStructure,
|
material: material_detail, // SS304/GRAPHITE/SS304/SS304
|
||||||
materialDetail: materialDetail,
|
|
||||||
thickness: thickness,
|
thickness: thickness,
|
||||||
grade: materialDetail, // 재질 상세를 grade로 표시
|
userRequirements: material.user_requirements?.join(', ') || '-',
|
||||||
quantity: purchaseQty,
|
purchaseQuantity: purchaseQty,
|
||||||
unit: '개',
|
|
||||||
isGasket: true
|
isGasket: true
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -85,17 +114,34 @@ const GasketMaterialsView = ({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sortConfig.key) {
|
if (sortConfig && sortConfig.key) {
|
||||||
filtered.sort((a, b) => {
|
filtered.sort((a, b) => {
|
||||||
const aInfo = parseGasketInfo(a);
|
const aInfo = parseGasketInfo(a);
|
||||||
const bInfo = parseGasketInfo(b);
|
const bInfo = parseGasketInfo(b);
|
||||||
const aValue = aInfo[sortConfig.key] || '';
|
|
||||||
const bValue = bInfo[sortConfig.key] || '';
|
if (!aInfo || !bInfo) return 0;
|
||||||
|
|
||||||
|
const aValue = aInfo[sortConfig.key];
|
||||||
|
const bValue = bInfo[sortConfig.key];
|
||||||
|
|
||||||
|
// 값이 없는 경우 처리
|
||||||
|
if (aValue === undefined && bValue === undefined) return 0;
|
||||||
|
if (aValue === undefined) return 1;
|
||||||
|
if (bValue === undefined) return -1;
|
||||||
|
|
||||||
|
// 숫자인 경우 숫자로 비교
|
||||||
|
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||||
|
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문자열로 비교
|
||||||
|
const aStr = String(aValue).toLowerCase();
|
||||||
|
const bStr = String(bValue).toLowerCase();
|
||||||
|
|
||||||
if (sortConfig.direction === 'asc') {
|
if (sortConfig.direction === 'asc') {
|
||||||
return aValue > bValue ? 1 : -1;
|
return aStr.localeCompare(bStr);
|
||||||
} else {
|
} else {
|
||||||
return aValue < bValue ? 1 : -1;
|
return bStr.localeCompare(aStr);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -147,28 +193,79 @@ const GasketMaterialsView = ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.post('/files/save-excel', {
|
console.log('🔄 가스켓 엑셀 내보내기 시작 - 새로운 방식');
|
||||||
|
|
||||||
|
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||||
|
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||||
|
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||||
|
category: 'GASKET',
|
||||||
|
filename: excelFileName,
|
||||||
|
uploadDate: new Date().toLocaleDateString()
|
||||||
|
});
|
||||||
|
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||||
|
|
||||||
|
// 2. 구매신청 생성
|
||||||
|
const allMaterialIds = selectedMaterialsData.map(m => m.id);
|
||||||
|
const response = await api.post('/purchase-request/create', {
|
||||||
file_id: fileId,
|
file_id: fileId,
|
||||||
|
job_no: jobNo,
|
||||||
category: 'GASKET',
|
category: 'GASKET',
|
||||||
materials: dataWithRequirements,
|
material_ids: allMaterialIds,
|
||||||
filename: excelFileName,
|
materials_data: dataWithRequirements.map(m => ({
|
||||||
user_id: user?.id
|
material_id: m.id,
|
||||||
|
description: m.original_description,
|
||||||
|
category: m.classified_category,
|
||||||
|
size: m.size_inch || m.size_spec,
|
||||||
|
schedule: m.schedule,
|
||||||
|
material_grade: m.material_grade || m.full_material_grade,
|
||||||
|
quantity: m.quantity,
|
||||||
|
unit: m.unit,
|
||||||
|
user_requirement: userRequirements[m.id] || ''
|
||||||
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
if (response.data.success) {
|
||||||
category: 'GASKET',
|
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||||
filename: excelFileName,
|
|
||||||
uploadDate: new Date().toLocaleDateString()
|
|
||||||
});
|
|
||||||
|
|
||||||
alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
|
// 3. 생성된 엑셀 파일을 서버에 업로드
|
||||||
|
console.log('📤 서버에 엑셀 파일 업로드 중...');
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('excel_file', excelBlob, excelFileName);
|
||||||
|
formData.append('request_id', response.data.request_id);
|
||||||
|
formData.append('category', 'GASKET');
|
||||||
|
|
||||||
|
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||||
|
|
||||||
|
if (onPurchasedMaterialsUpdate) {
|
||||||
|
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 클라이언트 다운로드
|
||||||
|
const url = window.URL.createObjectURL(excelBlob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = excelFileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('엑셀 저장 실패:', error);
|
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||||
|
// 실패 시에도 클라이언트 다운로드는 진행
|
||||||
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||||
category: 'GASKET',
|
category: 'GASKET',
|
||||||
filename: excelFileName,
|
filename: excelFileName,
|
||||||
uploadDate: new Date().toLocaleDateString()
|
uploadDate: new Date().toLocaleDateString()
|
||||||
});
|
});
|
||||||
|
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -236,20 +333,23 @@ const GasketMaterialsView = ({
|
|||||||
<div style={{
|
<div style={{
|
||||||
background: 'white',
|
background: 'white',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
overflow: 'hidden',
|
overflow: 'auto',
|
||||||
|
maxHeight: '600px',
|
||||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||||
}}>
|
}}>
|
||||||
|
<div style={{ minWidth: '1400px' }}>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
|
gridTemplateColumns: '50px 250px 120px 100px 120px 200px 100px 180px 150px 120px',
|
||||||
gap: '16px',
|
gap: '16px',
|
||||||
padding: '16px',
|
padding: '16px',
|
||||||
background: '#f8fafc',
|
background: '#f8fafc',
|
||||||
borderBottom: '1px solid #e2e8f0',
|
borderBottom: '1px solid #e2e8f0',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#374151'
|
color: '#374151',
|
||||||
|
textAlign: 'center'
|
||||||
}}>
|
}}>
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
@@ -262,18 +362,95 @@ const GasketMaterialsView = ({
|
|||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<FilterableHeader sortKey="subtype" filterKey="subtype">Type</FilterableHeader>
|
<FilterableHeader
|
||||||
<FilterableHeader sortKey="size" filterKey="size">Size</FilterableHeader>
|
sortKey="type"
|
||||||
<FilterableHeader sortKey="pressure" filterKey="pressure">Pressure</FilterableHeader>
|
filterKey="type"
|
||||||
<FilterableHeader sortKey="schedule" filterKey="schedule">Thickness</FilterableHeader>
|
sortConfig={sortConfig}
|
||||||
<FilterableHeader sortKey="grade" filterKey="grade">Material Grade</FilterableHeader>
|
onSort={handleSort}
|
||||||
<FilterableHeader sortKey="quantity" filterKey="quantity">Quantity</FilterableHeader>
|
columnFilters={columnFilters}
|
||||||
<div>Unit</div>
|
onFilterChange={setColumnFilters}
|
||||||
<div>User Requirement</div>
|
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>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="structure"
|
||||||
|
filterKey="structure"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Structure
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="material"
|
||||||
|
filterKey="material"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Material
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="thickness"
|
||||||
|
filterKey="thickness"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Thickness
|
||||||
|
</FilterableHeader>
|
||||||
|
<div>User Requirements</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
{/* 데이터 행들 */}
|
{/* 데이터 행들 */}
|
||||||
<div style={{ maxHeight: '600px', overflowY: 'auto' }}>
|
|
||||||
{filteredMaterials.map((material, index) => {
|
{filteredMaterials.map((material, index) => {
|
||||||
const info = parseGasketInfo(material);
|
const info = parseGasketInfo(material);
|
||||||
const isSelected = selectedMaterials.has(material.id);
|
const isSelected = selectedMaterials.has(material.id);
|
||||||
@@ -284,7 +461,7 @@ const GasketMaterialsView = ({
|
|||||||
key={material.id}
|
key={material.id}
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
|
gridTemplateColumns: '50px 250px 120px 100px 120px 200px 100px 180px 150px 120px',
|
||||||
gap: '16px',
|
gap: '16px',
|
||||||
padding: '16px',
|
padding: '16px',
|
||||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||||
@@ -314,8 +491,8 @@ const GasketMaterialsView = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500', textAlign: 'center' }}>
|
||||||
{info.subtype}
|
{info.type}
|
||||||
{isPurchased && (
|
{isPurchased && (
|
||||||
<span style={{
|
<span style={{
|
||||||
marginLeft: '8px',
|
marginLeft: '8px',
|
||||||
@@ -330,23 +507,30 @@ const GasketMaterialsView = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
{info.size}
|
{info.size}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
{info.pressure}
|
{info.pressure}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
{info.schedule}
|
{info.structure}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
{info.grade}
|
{info.material}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
{info.quantity}
|
{info.thickness}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '14px', color: '#6b7280' }}>
|
<div style={{
|
||||||
{info.unit}
|
fontSize: '14px',
|
||||||
|
color: '#1f2937',
|
||||||
|
textAlign: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}}>
|
||||||
|
{info.userRequirements}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
@@ -356,7 +540,7 @@ const GasketMaterialsView = ({
|
|||||||
...userRequirements,
|
...userRequirements,
|
||||||
[material.id]: e.target.value
|
[material.id]: e.target.value
|
||||||
})}
|
})}
|
||||||
placeholder="Enter requirement..."
|
placeholder="Enter additional request..."
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '6px 8px',
|
padding: '6px 8px',
|
||||||
@@ -366,6 +550,9 @@ const GasketMaterialsView = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center', fontWeight: '500' }}>
|
||||||
|
{info.purchaseQuantity.toLocaleString()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -66,17 +66,34 @@ const SupportMaterialsView = ({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sortConfig.key) {
|
if (sortConfig && sortConfig.key) {
|
||||||
filtered.sort((a, b) => {
|
filtered.sort((a, b) => {
|
||||||
const aInfo = parseSupportInfo(a);
|
const aInfo = parseSupportInfo(a);
|
||||||
const bInfo = parseSupportInfo(b);
|
const bInfo = parseSupportInfo(b);
|
||||||
const aValue = aInfo[sortConfig.key] || '';
|
|
||||||
const bValue = bInfo[sortConfig.key] || '';
|
if (!aInfo || !bInfo) return 0;
|
||||||
|
|
||||||
|
const aValue = aInfo[sortConfig.key];
|
||||||
|
const bValue = bInfo[sortConfig.key];
|
||||||
|
|
||||||
|
// 값이 없는 경우 처리
|
||||||
|
if (aValue === undefined && bValue === undefined) return 0;
|
||||||
|
if (aValue === undefined) return 1;
|
||||||
|
if (bValue === undefined) return -1;
|
||||||
|
|
||||||
|
// 숫자인 경우 숫자로 비교
|
||||||
|
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||||
|
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문자열로 비교
|
||||||
|
const aStr = String(aValue).toLowerCase();
|
||||||
|
const bStr = String(bValue).toLowerCase();
|
||||||
|
|
||||||
if (sortConfig.direction === 'asc') {
|
if (sortConfig.direction === 'asc') {
|
||||||
return aValue > bValue ? 1 : -1;
|
return aStr.localeCompare(bStr);
|
||||||
} else {
|
} else {
|
||||||
return aValue < bValue ? 1 : -1;
|
return bStr.localeCompare(aStr);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,17 +94,34 @@ const ValveMaterialsView = ({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sortConfig.key) {
|
if (sortConfig && sortConfig.key) {
|
||||||
filtered.sort((a, b) => {
|
filtered.sort((a, b) => {
|
||||||
const aInfo = parseValveInfo(a);
|
const aInfo = parseValveInfo(a);
|
||||||
const bInfo = parseValveInfo(b);
|
const bInfo = parseValveInfo(b);
|
||||||
const aValue = aInfo[sortConfig.key] || '';
|
|
||||||
const bValue = bInfo[sortConfig.key] || '';
|
if (!aInfo || !bInfo) return 0;
|
||||||
|
|
||||||
|
const aValue = aInfo[sortConfig.key];
|
||||||
|
const bValue = bInfo[sortConfig.key];
|
||||||
|
|
||||||
|
// 값이 없는 경우 처리
|
||||||
|
if (aValue === undefined && bValue === undefined) return 0;
|
||||||
|
if (aValue === undefined) return 1;
|
||||||
|
if (bValue === undefined) return -1;
|
||||||
|
|
||||||
|
// 숫자인 경우 숫자로 비교
|
||||||
|
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||||
|
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문자열로 비교
|
||||||
|
const aStr = String(aValue).toLowerCase();
|
||||||
|
const bStr = String(bValue).toLowerCase();
|
||||||
|
|
||||||
if (sortConfig.direction === 'asc') {
|
if (sortConfig.direction === 'asc') {
|
||||||
return aValue > bValue ? 1 : -1;
|
return aStr.localeCompare(bStr);
|
||||||
} else {
|
} else {
|
||||||
return aValue < bValue ? 1 : -1;
|
return bStr.localeCompare(aStr);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const FilterableHeader = ({
|
|||||||
style={{ cursor: 'pointer', flex: 1 }}
|
style={{ cursor: 'pointer', flex: 1 }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{sortConfig.key === sortKey && (
|
{sortConfig && sortConfig.key === sortKey && (
|
||||||
<span style={{ marginLeft: '4px' }}>
|
<span style={{ marginLeft: '4px' }}>
|
||||||
{sortConfig.direction === 'asc' ? '↑' : '↓'}
|
{sortConfig.direction === 'asc' ? '↑' : '↓'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -422,37 +422,43 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (category === 'GASKET') {
|
} else if (category === 'GASKET') {
|
||||||
// 가스켓 상세 타입 표시
|
// BOM 페이지의 타입을 따라가도록 - 프론트엔드 parseGasketInfo와 동일한 로직
|
||||||
const gasketDetails = material.gasket_details || {};
|
const gasketDetails = material.gasket_details || {};
|
||||||
const gasketType = gasketDetails.gasket_type || '';
|
const gasketType = gasketDetails.gasket_type || '';
|
||||||
const gasketSubtype = gasketDetails.gasket_subtype || '';
|
|
||||||
|
|
||||||
if (gasketType === 'SPIRAL_WOUND') {
|
// 가스켓 타입 매핑 (프론트엔드와 동일)
|
||||||
itemName = '스파이럴 워운드 가스켓';
|
const gasketTypeMap = {
|
||||||
} else if (gasketType === 'RING_JOINT') {
|
'SWG': 'SPIRAL WOUND GASKET',
|
||||||
itemName = '링 조인트 가스켓';
|
'SPIRAL_WOUND': 'SPIRAL WOUND GASKET',
|
||||||
} else if (gasketType === 'FULL_FACE') {
|
'RTJ': 'RING TYPE JOINT GASKET',
|
||||||
itemName = '풀 페이스 가스켓';
|
'RING_JOINT': 'RING TYPE JOINT GASKET',
|
||||||
} else if (gasketType === 'RAISED_FACE') {
|
'FF': 'FULL FACE GASKET',
|
||||||
itemName = '레이즈드 페이스 가스켓';
|
'FULL_FACE': 'FULL FACE GASKET',
|
||||||
} else if (gasketSubtype && gasketSubtype !== gasketType) {
|
'RF': 'RAISED FACE GASKET',
|
||||||
itemName = gasketSubtype;
|
'RAISED_FACE': 'RAISED FACE GASKET'
|
||||||
} else if (gasketType) {
|
};
|
||||||
itemName = gasketType;
|
|
||||||
} else {
|
// Description에서 가스켓 타입 추출
|
||||||
// gasket_details가 없으면 description에서 추출
|
const descUpper = cleanDescription.toUpperCase();
|
||||||
const desc = cleanDescription.toUpperCase();
|
let extractedType = '';
|
||||||
if (desc.includes('SWG') || desc.includes('SPIRAL')) {
|
|
||||||
itemName = '스파이럴 워운드 가스켓';
|
if (descUpper.includes('SWG') || descUpper.includes('SPIRAL')) {
|
||||||
} else if (desc.includes('RTJ') || desc.includes('RING')) {
|
extractedType = 'SWG';
|
||||||
itemName = '링 조인트 가스켓';
|
} else if (descUpper.includes('RTJ') || descUpper.includes('RING')) {
|
||||||
} else if (desc.includes('FF') || desc.includes('FULL FACE')) {
|
extractedType = 'RTJ';
|
||||||
itemName = '풀 페이스 가스켓';
|
} else if (descUpper.includes('FF') || descUpper.includes('FULL FACE')) {
|
||||||
} else if (desc.includes('RF') || desc.includes('RAISED')) {
|
extractedType = 'FF';
|
||||||
itemName = '레이즈드 페이스 가스켓';
|
} else if (descUpper.includes('RF') || descUpper.includes('RAISED')) {
|
||||||
} else {
|
extractedType = 'RF';
|
||||||
itemName = '가스켓';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 풀네임으로 변환
|
||||||
|
if (gasketType && gasketTypeMap[gasketType]) {
|
||||||
|
itemName = gasketTypeMap[gasketType];
|
||||||
|
} else if (extractedType && gasketTypeMap[extractedType]) {
|
||||||
|
itemName = gasketTypeMap[extractedType];
|
||||||
|
} else {
|
||||||
|
itemName = 'GASKET';
|
||||||
}
|
}
|
||||||
} else if (category === 'BOLT') {
|
} else if (category === 'BOLT') {
|
||||||
// 볼트 상세 타입 표시
|
// 볼트 상세 타입 표시
|
||||||
@@ -660,31 +666,36 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
|||||||
|
|
||||||
detailInfo = surfaceTreatments.join(', ') || '-';
|
detailInfo = surfaceTreatments.join(', ') || '-';
|
||||||
} else if (category === 'GASKET') {
|
} else if (category === 'GASKET') {
|
||||||
// 실제 재질 구성 정보 (SS304/GRAPHITE/SS304/SS304)
|
// 실제 재질 구성 정보 - description에서 우선 추출
|
||||||
|
// SS304/GRAPHITE/SS304/SS304 패턴 먼저 찾기
|
||||||
|
const fullMaterialMatch = cleanDescription.match(/SS304\/GRAPHITE\/SS304\/SS304/i);
|
||||||
|
if (fullMaterialMatch) {
|
||||||
|
gasketMaterial = 'SS304/GRAPHITE/SS304/SS304';
|
||||||
|
} else {
|
||||||
|
// 4개 재질 패턴 (다양한 재질 조합)
|
||||||
|
const fourMaterialMatch = cleanDescription.match(/(SS\d+|304|316|CS)\/(GRAPHITE|PTFE|VITON|EPDM)\/(SS\d+|304|316|CS)\/(SS\d+|304|316|CS)/i);
|
||||||
|
if (fourMaterialMatch) {
|
||||||
|
gasketMaterial = `${fourMaterialMatch[1]}/${fourMaterialMatch[2]}/${fourMaterialMatch[3]}/${fourMaterialMatch[4]}`;
|
||||||
|
} else {
|
||||||
|
// DB에서 가져온 정보로 구성 (fallback)
|
||||||
if (material.gasket_details) {
|
if (material.gasket_details) {
|
||||||
const materialType = material.gasket_details.material_type || '';
|
const materialType = material.gasket_details.material_type || '';
|
||||||
const fillerMaterial = material.gasket_details.filler_material || '';
|
const fillerMaterial = material.gasket_details.filler_material || '';
|
||||||
|
|
||||||
if (materialType && fillerMaterial) {
|
if (materialType && fillerMaterial) {
|
||||||
// DB에서 가져온 정보로 구성
|
|
||||||
gasketMaterial = `${materialType}/${fillerMaterial}`;
|
gasketMaterial = `${materialType}/${fillerMaterial}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// gasket_details가 없거나 불완전하면 description에서 추출
|
// 마지막으로 간단한 패턴
|
||||||
if (!gasketMaterial) {
|
if (!gasketMaterial) {
|
||||||
// SS304/GRAPHITE/SS304/SS304 패턴 추출
|
|
||||||
const fullMaterialMatch = cleanDescription.match(/SS304\/GRAPHITE\/SS304\/SS304/i);
|
|
||||||
if (fullMaterialMatch) {
|
|
||||||
gasketMaterial = 'SS304/GRAPHITE/SS304/SS304';
|
|
||||||
} else {
|
|
||||||
// 간단한 패턴 (SS304/GRAPHITE)
|
|
||||||
const simpleMaterialMatch = cleanDescription.match(/(SS\d+|304|316)\s*[\/+]\s*(GRAPHITE|PTFE|VITON|EPDM)/i);
|
const simpleMaterialMatch = cleanDescription.match(/(SS\d+|304|316)\s*[\/+]\s*(GRAPHITE|PTFE|VITON|EPDM)/i);
|
||||||
if (simpleMaterialMatch) {
|
if (simpleMaterialMatch) {
|
||||||
gasketMaterial = `${simpleMaterialMatch[1]}/${simpleMaterialMatch[2]}`;
|
gasketMaterial = `${simpleMaterialMatch[1]}/${simpleMaterialMatch[2]}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 두께 정보 별도 추출
|
// 두께 정보 별도 추출
|
||||||
if (material.gasket_details && material.gasket_details.thickness) {
|
if (material.gasket_details && material.gasket_details.thickness) {
|
||||||
@@ -794,17 +805,41 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
|||||||
base['관리항목4'] = ''; // N열
|
base['관리항목4'] = ''; // N열
|
||||||
base['관리항목5'] = ''; // O열
|
base['관리항목5'] = ''; // O열
|
||||||
} else if (category === 'GASKET') {
|
} else if (category === 'GASKET') {
|
||||||
// 가스켓 전용 컬럼 (F~O) - 타입 제거, 품목명에 포함됨
|
// 가스켓 전용 컬럼 (F~O) - 재질을 2개로 분리
|
||||||
|
// 재질 분리 로직: SS304/GRAPHITE/SS304/SS304 → 재질1: SS304/GRAPHITE, 재질2: /SS304/SS304
|
||||||
|
let material1 = '-';
|
||||||
|
let material2 = '-';
|
||||||
|
|
||||||
|
if (gasketMaterial && gasketMaterial.includes('/')) {
|
||||||
|
const materialParts = gasketMaterial.split('/');
|
||||||
|
|
||||||
|
if (materialParts.length >= 4) {
|
||||||
|
// 4개 재질인 경우: SS304/GRAPHITE/SS304/SS304
|
||||||
|
material1 = `${materialParts[0]}/${materialParts[1]}`;
|
||||||
|
material2 = `/${materialParts[2]}/${materialParts[3]}`;
|
||||||
|
} else if (materialParts.length === 3) {
|
||||||
|
// 3개 재질인 경우: SS304/GRAPHITE/SS304
|
||||||
|
material1 = `${materialParts[0]}/${materialParts[1]}`;
|
||||||
|
material2 = `/${materialParts[2]}`;
|
||||||
|
} else if (materialParts.length === 2) {
|
||||||
|
// 2개 재질인 경우: SS304/GRAPHITE
|
||||||
|
material1 = gasketMaterial;
|
||||||
|
material2 = '-';
|
||||||
|
}
|
||||||
|
} else if (gasketMaterial) {
|
||||||
|
material1 = gasketMaterial;
|
||||||
|
}
|
||||||
|
|
||||||
base['크기'] = material.size_spec || '-'; // F열
|
base['크기'] = material.size_spec || '-'; // F열
|
||||||
base['압력등급'] = pressure; // G열
|
base['압력등급'] = pressure; // G열
|
||||||
base['구조'] = grade; // H열: H/F/I/O, SWG 등 (타입 정보 제거)
|
base['구조'] = grade; // H열: H/F/I/O, SWG 등
|
||||||
base['재질'] = gasketMaterial || '-'; // I열: SS304/GRAPHITE/SS304/SS304
|
base['재질1'] = material1; // I열: SS304/GRAPHITE
|
||||||
base['두께'] = gasketThickness || '-'; // J열: 4.5mm
|
base['재질2'] = material2; // J열: SS304/SS304
|
||||||
base['사용자요구'] = material.user_requirement || ''; // K열
|
base['두께'] = gasketThickness || '-'; // K열: 4.5mm
|
||||||
base['관리항목1'] = ''; // L열
|
base['사용자요구'] = material.user_requirement || ''; // L열
|
||||||
base['관리항목2'] = ''; // M열
|
base['관리항목1'] = ''; // M열
|
||||||
base['관리항목3'] = ''; // N열
|
base['관리항목2'] = ''; // N열
|
||||||
base['관리항목4'] = ''; // O열
|
base['관리항목3'] = ''; // O열
|
||||||
} else if (category === 'BOLT') {
|
} else if (category === 'BOLT') {
|
||||||
// 볼트 전용 컬럼 (F~O) - 타입 제거, 품목명에 포함됨
|
// 볼트 전용 컬럼 (F~O) - 타입 제거, 품목명에 포함됨
|
||||||
base['크기'] = material.size_spec || '-'; // F열
|
base['크기'] = material.size_spec || '-'; // F열
|
||||||
@@ -1077,7 +1112,7 @@ export const createExcelBlob = async (materials, filename, options = {}) => {
|
|||||||
'FITTING': ['크기', '압력등급', '스케줄', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
|
'FITTING': ['크기', '압력등급', '스케줄', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
|
||||||
'FLANGE': ['크기', '페이싱', '압력등급', '스케줄', '재질', '추가요구사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
|
'FLANGE': ['크기', '페이싱', '압력등급', '스케줄', '재질', '추가요구사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
|
||||||
'VALVE': ['크기', '압력등급', '재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
|
'VALVE': ['크기', '압력등급', '재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
|
||||||
'GASKET': ['크기', '압력등급', '구조', '재질', '두께', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
|
'GASKET': ['크기', '압력등급', '구조', '재질1', '재질2', '두께', '사용자요구', '관리항목1', '관리항목2', '관리항목3'],
|
||||||
'BOLT': ['크기', '압력등급', '길이', '재질', '추가요구', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
|
'BOLT': ['크기', '압력등급', '길이', '재질', '추가요구', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
|
||||||
'SUPPORT': ['크기', '압력등급', '재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
|
'SUPPORT': ['크기', '압력등급', '재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user