feat: 품목관리 파일 업로드 기능 개선
- 파일 업로드 API에 field_key, file_id 파라미터 추가 - ItemMaster 타입에 files 필드 추가 (새 API 구조 지원) - DynamicItemForm에서 files 객체 파싱 로직 추가 - 시방서/인정서 파일 UI 개선: 파일명 표시 + 다운로드/수정/삭제 버튼 - 기존 API 구조와 새 API 구조 모두 지원 (폴백 처리) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,22 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목코드 중복 에러 클래스
|
||||
* - 백엔드에서 400 에러와 함께 duplicate_id, duplicate_code 반환 시 사용
|
||||
* - 2025-12-11: 백엔드 DuplicateCodeException 대응
|
||||
*/
|
||||
export class DuplicateCodeError extends ApiError {
|
||||
constructor(
|
||||
public message: string,
|
||||
public duplicateId: number,
|
||||
public duplicateCode: string
|
||||
) {
|
||||
super(400, message);
|
||||
this.name = 'DuplicateCodeError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 에러를 처리하고 ApiError를 throw
|
||||
* @param response - fetch Response 객체
|
||||
@@ -25,9 +41,15 @@ export const handleApiError = async (response: Response): Promise<never> => {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
// 401 Unauthorized - 토큰 만료 또는 인증 실패
|
||||
// ✅ 자동 리다이렉트 제거: 각 페이지에서 에러를 직접 처리하도록 변경
|
||||
// 이를 통해 개발자가 Network 탭에서 에러를 확인할 수 있음
|
||||
// ✅ 자동으로 로그인 페이지로 리다이렉트
|
||||
if (response.status === 401) {
|
||||
console.warn('⚠️ 401 Unauthorized - 로그인 페이지로 이동합니다.');
|
||||
|
||||
// 클라이언트 사이드에서만 리다이렉트
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
throw new ApiError(
|
||||
401,
|
||||
data.message || '인증이 필요합니다. 로그인 상태를 확인해주세요.',
|
||||
@@ -44,6 +66,21 @@ export const handleApiError = async (response: Response): Promise<never> => {
|
||||
);
|
||||
}
|
||||
|
||||
// 400 Bad Request - 품목코드 중복 에러 체크
|
||||
// 백엔드 DuplicateCodeException이 duplicate_id, duplicate_code 반환
|
||||
if (response.status === 400 && data.duplicate_id) {
|
||||
console.warn('⚠️ 품목코드 중복 감지:', {
|
||||
duplicateId: data.duplicate_id,
|
||||
duplicateCode: data.duplicate_code,
|
||||
message: data.message
|
||||
});
|
||||
throw new DuplicateCodeError(
|
||||
data.message || '해당 품목코드가 이미 존재합니다.',
|
||||
data.duplicate_id,
|
||||
data.duplicate_code
|
||||
);
|
||||
}
|
||||
|
||||
// 422 Unprocessable Entity - Validation 에러
|
||||
if (response.status === 422) {
|
||||
// 상세 validation 에러 로그 출력
|
||||
|
||||
@@ -327,6 +327,10 @@ export type ItemFileType = 'specification' | 'certification' | 'bending_diagram'
|
||||
|
||||
/** 파일 업로드 옵션 */
|
||||
export interface UploadFileOptions {
|
||||
/** 필드 키 (백엔드에서 파일 식별용) - 예: specification_file, certification_file, bending_diagram */
|
||||
fieldKey?: string;
|
||||
/** 파일 ID (같은 field_key 내 여러 파일 구분용) - 0, 1, 2... (없으면 최초 등록, 있으면 덮어쓰기) */
|
||||
fileId?: number;
|
||||
/** 인증번호 (certification 타입일 때) */
|
||||
certificationNumber?: string;
|
||||
/** 인증 시작일 (certification 타입일 때) */
|
||||
@@ -384,6 +388,16 @@ export async function uploadItemFile(
|
||||
formData.append('file', file);
|
||||
formData.append('type', fileType);
|
||||
|
||||
// field_key, file_id 추가 (백엔드에서 파일 식별용)
|
||||
// - field_key: 파일 종류 식별자 (예: specification_file, certification_file, bending_diagram)
|
||||
// - file_id: 같은 field_key 내 파일 순번 (없으면 최초 등록, 있으면 해당 파일 덮어쓰기)
|
||||
if (options?.fieldKey) {
|
||||
formData.append('field_key', options.fieldKey);
|
||||
}
|
||||
if (options?.fileId !== undefined) {
|
||||
formData.append('file_id', String(options.fileId));
|
||||
}
|
||||
|
||||
// certification 관련 추가 필드
|
||||
if (fileType === 'certification' && options) {
|
||||
if (options.certificationNumber) {
|
||||
@@ -398,8 +412,13 @@ export async function uploadItemFile(
|
||||
}
|
||||
|
||||
// bending_diagram 관련 추가 필드
|
||||
// 백엔드가 배열 형태를 기대하므로 각 항목을 개별적으로 append
|
||||
if (fileType === 'bending_diagram' && options?.bendingDetails) {
|
||||
formData.append('bending_details', JSON.stringify(options.bendingDetails));
|
||||
options.bendingDetails.forEach((detail, index) => {
|
||||
Object.entries(detail).forEach(([key, value]) => {
|
||||
formData.append(`bending_details[${index}][${key}]`, String(value));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 프록시 경유: /api/proxy/items/{id}/files → /api/v1/items/{id}/files
|
||||
@@ -550,6 +569,84 @@ export async function checkItemCodeAvailability(
|
||||
return data.data.available;
|
||||
}
|
||||
|
||||
/** 품목 코드 중복 체크 결과 */
|
||||
export interface DuplicateCheckResult {
|
||||
/** 중복 여부 */
|
||||
isDuplicate: boolean;
|
||||
/** 중복된 품목 ID (중복인 경우에만 존재) */
|
||||
duplicateId?: number;
|
||||
/** 중복된 품목 타입 (중복인 경우에만 존재) */
|
||||
duplicateItemType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 코드 중복 체크 (ID 반환)
|
||||
*
|
||||
* GET /api/v1/items/code/{code} API를 활용하여 중복 체크
|
||||
* - 200 OK: 중복 있음 (해당 품목의 id 반환)
|
||||
* - 404 Not Found: 중복 없음
|
||||
*
|
||||
* @param itemCode - 체크할 품목 코드
|
||||
* @param excludeId - 제외할 품목 ID (수정 시 자기 자신 제외)
|
||||
* @returns 중복 체크 결과 (중복 여부 + 중복 품목 ID)
|
||||
*
|
||||
* @example
|
||||
* // 등록 시
|
||||
* const result = await checkItemCodeDuplicate('PT-ASM-001');
|
||||
* if (result.isDuplicate) {
|
||||
* // 중복! result.duplicateId로 수정 페이지 이동 가능
|
||||
* }
|
||||
*
|
||||
* // 수정 시 (자기 자신 제외)
|
||||
* const result = await checkItemCodeDuplicate('PT-ASM-001', currentItemId);
|
||||
*/
|
||||
export async function checkItemCodeDuplicate(
|
||||
itemCode: string,
|
||||
excludeId?: number
|
||||
): Promise<DuplicateCheckResult> {
|
||||
try {
|
||||
// 프록시 경유: /api/proxy/items/code/{code} → /api/v1/items/code/{code}
|
||||
const response = await fetch(
|
||||
`/api/proxy/items/code/${encodeURIComponent(itemCode)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 404) {
|
||||
// 404: 해당 코드의 품목이 없음 → 중복 아님
|
||||
return { isDuplicate: false };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// 다른 에러는 중복 아님으로 처리 (안전한 방향)
|
||||
console.warn('[checkItemCodeDuplicate] API 에러:', response.status);
|
||||
return { isDuplicate: false };
|
||||
}
|
||||
|
||||
// 200 OK: 해당 코드의 품목이 존재함
|
||||
const data = await response.json();
|
||||
const duplicateItem = data.data;
|
||||
|
||||
// 수정 모드에서 자기 자신인 경우 제외
|
||||
if (excludeId && duplicateItem.id === excludeId) {
|
||||
return { isDuplicate: false };
|
||||
}
|
||||
|
||||
return {
|
||||
isDuplicate: true,
|
||||
duplicateId: duplicateItem.id,
|
||||
// 백엔드에서 product_type, item_type, type_code 등 다양한 필드명 사용 가능
|
||||
duplicateItemType: duplicateItem.product_type || duplicateItem.item_type || duplicateItem.type_code,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[checkItemCodeDuplicate] 에러:', error);
|
||||
// 에러 시 중복 아님으로 처리 (등록/수정 진행 허용)
|
||||
return { isDuplicate: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 품목 코드 생성 (서버에서 자동 생성)
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user