2025-11-18 14:17:52 +09:00
|
|
|
/**
|
|
|
|
|
* 품목 관리 API 클라이언트
|
|
|
|
|
*
|
|
|
|
|
* Laravel 백엔드와 통신하는 API 함수들
|
|
|
|
|
* Next.js 15 Server Components와 Client Components 모두에서 사용 가능
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import type {
|
|
|
|
|
ItemMaster,
|
|
|
|
|
CreateItemData,
|
|
|
|
|
UpdateItemData,
|
|
|
|
|
FetchItemsParams,
|
|
|
|
|
ApiResponse,
|
|
|
|
|
PaginatedResponse,
|
|
|
|
|
BOMLine,
|
|
|
|
|
} from '@/types/item';
|
|
|
|
|
|
|
|
|
|
// ===== 환경 변수 =====
|
|
|
|
|
|
|
|
|
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
|
|
|
|
|
|
|
|
|
// ===== 유틸리티 함수 =====
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 인증 토큰 가져오기
|
|
|
|
|
* TODO: 실제 인증 구현에 맞게 수정 필요
|
|
|
|
|
*/
|
|
|
|
|
function getAuthToken(): string | null {
|
|
|
|
|
// Server Component에서는 쿠키에서 자동으로
|
|
|
|
|
// Client Component에서는 localStorage 또는 쿠키에서
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
return localStorage.getItem('auth_token');
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Fetch 옵션 생성
|
|
|
|
|
*/
|
|
|
|
|
function createFetchOptions(options: RequestInit = {}): RequestInit {
|
|
|
|
|
const token = getAuthToken();
|
2025-11-25 21:07:10 +09:00
|
|
|
const headers: Record<string, string> = {
|
2025-11-18 14:17:52 +09:00
|
|
|
'Content-Type': 'application/json',
|
2025-11-25 21:07:10 +09:00
|
|
|
...(options.headers as Record<string, string>),
|
2025-11-18 14:17:52 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (token) {
|
|
|
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...options,
|
|
|
|
|
headers,
|
|
|
|
|
credentials: 'include', // 쿠키 포함
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* API 에러 처리
|
|
|
|
|
*/
|
|
|
|
|
async function handleApiResponse<T>(response: Response): Promise<T> {
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const error = await response.json().catch(() => ({
|
|
|
|
|
message: 'API 요청 실패',
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
throw new Error(error.message || `HTTP ${response.status}: ${response.statusText}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
return data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 품목 CRUD =====
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 품목 목록 조회
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* // Server Component에서
|
|
|
|
|
* const items = await fetchItems({ itemType: 'FG' });
|
|
|
|
|
*
|
|
|
|
|
* // Client Component에서
|
|
|
|
|
* const [items, setItems] = useState([]);
|
|
|
|
|
* useEffect(() => {
|
|
|
|
|
* fetchItems().then(setItems);
|
|
|
|
|
* }, []);
|
|
|
|
|
*/
|
|
|
|
|
export async function fetchItems(
|
|
|
|
|
params?: FetchItemsParams
|
|
|
|
|
): Promise<ItemMaster[]> {
|
|
|
|
|
const queryParams = new URLSearchParams();
|
|
|
|
|
|
|
|
|
|
if (params) {
|
|
|
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
|
|
|
if (value !== undefined && value !== null) {
|
|
|
|
|
queryParams.append(key, String(value));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const url = `${API_URL}/api/items${queryParams.toString() ? `?${queryParams}` : ''}`;
|
|
|
|
|
|
|
|
|
|
const response = await fetch(url, createFetchOptions());
|
|
|
|
|
const data = await handleApiResponse<ApiResponse<ItemMaster[]>>(response);
|
|
|
|
|
|
|
|
|
|
return data.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 품목 목록 조회 (페이지네이션)
|
|
|
|
|
*/
|
|
|
|
|
export async function fetchItemsPaginated(
|
|
|
|
|
params?: FetchItemsParams
|
|
|
|
|
): Promise<PaginatedResponse<ItemMaster>> {
|
|
|
|
|
const queryParams = new URLSearchParams();
|
|
|
|
|
|
|
|
|
|
if (params) {
|
|
|
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
|
|
|
if (value !== undefined && value !== null) {
|
|
|
|
|
queryParams.append(key, String(value));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const url = `${API_URL}/api/items/paginated${queryParams.toString() ? `?${queryParams}` : ''}`;
|
|
|
|
|
|
|
|
|
|
const response = await fetch(url, createFetchOptions());
|
|
|
|
|
const data = await handleApiResponse<ApiResponse<PaginatedResponse<ItemMaster>>>(response);
|
|
|
|
|
|
|
|
|
|
return data.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 품목 상세 조회
|
|
|
|
|
*
|
|
|
|
|
* @param itemCode - 품목 코드 (예: "KD-FG-001")
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* const item = await fetchItemByCode('KD-FG-001');
|
|
|
|
|
*/
|
|
|
|
|
export async function fetchItemByCode(itemCode: string): Promise<ItemMaster> {
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`${API_URL}/api/items/${encodeURIComponent(itemCode)}`,
|
|
|
|
|
createFetchOptions()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const data = await handleApiResponse<ApiResponse<ItemMaster>>(response);
|
|
|
|
|
return data.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 품목 등록
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* const newItem = await createItem({
|
|
|
|
|
* itemCode: 'KD-FG-001',
|
|
|
|
|
* itemName: '스크린 제품',
|
|
|
|
|
* itemType: 'FG',
|
|
|
|
|
* unit: 'EA',
|
|
|
|
|
* });
|
|
|
|
|
*/
|
|
|
|
|
export async function createItem(
|
|
|
|
|
itemData: CreateItemData
|
|
|
|
|
): Promise<ItemMaster> {
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`${API_URL}/api/items`,
|
|
|
|
|
createFetchOptions({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(itemData),
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const data = await handleApiResponse<ApiResponse<ItemMaster>>(response);
|
|
|
|
|
return data.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 품목 수정
|
|
|
|
|
*
|
|
|
|
|
* @param itemCode - 품목 코드
|
|
|
|
|
* @param updates - 수정할 필드들
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* const updatedItem = await updateItem('KD-FG-001', {
|
|
|
|
|
* itemName: '스크린 제품 (수정)',
|
|
|
|
|
* salesPrice: 150000,
|
|
|
|
|
* });
|
|
|
|
|
*/
|
|
|
|
|
export async function updateItem(
|
|
|
|
|
itemCode: string,
|
|
|
|
|
updates: UpdateItemData
|
|
|
|
|
): Promise<ItemMaster> {
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`${API_URL}/api/items/${encodeURIComponent(itemCode)}`,
|
|
|
|
|
createFetchOptions({
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
body: JSON.stringify(updates),
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const data = await handleApiResponse<ApiResponse<ItemMaster>>(response);
|
|
|
|
|
return data.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 품목 삭제
|
|
|
|
|
*
|
|
|
|
|
* @param itemCode - 품목 코드
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* await deleteItem('KD-FG-001');
|
|
|
|
|
*/
|
|
|
|
|
export async function deleteItem(itemCode: string): Promise<void> {
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`${API_URL}/api/items/${encodeURIComponent(itemCode)}`,
|
|
|
|
|
createFetchOptions({
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await handleApiResponse<ApiResponse<null>>(response);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== BOM (자재명세서) 관리 =====
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* BOM 목록 조회
|
|
|
|
|
*
|
|
|
|
|
* @param itemCode - 상위 품목 코드
|
|
|
|
|
*/
|
|
|
|
|
export async function fetchBOM(itemCode: string): Promise<BOMLine[]> {
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom`,
|
|
|
|
|
createFetchOptions()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const data = await handleApiResponse<ApiResponse<BOMLine[]>>(response);
|
|
|
|
|
return data.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* BOM 계층구조 조회 (트리 형태)
|
|
|
|
|
*
|
|
|
|
|
* @param itemCode - 상위 품목 코드
|
|
|
|
|
*/
|
|
|
|
|
export async function fetchBOMTree(itemCode: string): Promise<any> {
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom/tree`,
|
|
|
|
|
createFetchOptions()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const data = await handleApiResponse<ApiResponse<any>>(response);
|
|
|
|
|
return data.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* BOM 라인 추가
|
|
|
|
|
*
|
|
|
|
|
* @param itemCode - 상위 품목 코드
|
|
|
|
|
* @param bomLine - BOM 라인 데이터
|
|
|
|
|
*/
|
|
|
|
|
export async function addBOMLine(
|
|
|
|
|
itemCode: string,
|
|
|
|
|
bomLine: Omit<BOMLine, 'id'>
|
|
|
|
|
): Promise<BOMLine> {
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom`,
|
|
|
|
|
createFetchOptions({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(bomLine),
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const data = await handleApiResponse<ApiResponse<BOMLine>>(response);
|
|
|
|
|
return data.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* BOM 라인 수정
|
|
|
|
|
*
|
|
|
|
|
* @param itemCode - 상위 품목 코드
|
|
|
|
|
* @param lineId - BOM 라인 ID
|
|
|
|
|
* @param updates - 수정할 필드들
|
|
|
|
|
*/
|
|
|
|
|
export async function updateBOMLine(
|
|
|
|
|
itemCode: string,
|
|
|
|
|
lineId: string,
|
|
|
|
|
updates: Partial<BOMLine>
|
|
|
|
|
): Promise<BOMLine> {
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom/${lineId}`,
|
|
|
|
|
createFetchOptions({
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
body: JSON.stringify(updates),
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const data = await handleApiResponse<ApiResponse<BOMLine>>(response);
|
|
|
|
|
return data.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* BOM 라인 삭제
|
|
|
|
|
*
|
|
|
|
|
* @param itemCode - 상위 품목 코드
|
|
|
|
|
* @param lineId - BOM 라인 ID
|
|
|
|
|
*/
|
|
|
|
|
export async function deleteBOMLine(
|
|
|
|
|
itemCode: string,
|
|
|
|
|
lineId: string
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom/${lineId}`,
|
|
|
|
|
createFetchOptions({
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await handleApiResponse<ApiResponse<null>>(response);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 파일 업로드 =====
|
|
|
|
|
|
2025-12-06 11:36:38 +09:00
|
|
|
/** 파일 타입 */
|
|
|
|
|
export type ItemFileType = 'specification' | 'certification' | 'bending_diagram';
|
|
|
|
|
|
|
|
|
|
/** 파일 업로드 옵션 */
|
|
|
|
|
export interface UploadFileOptions {
|
2025-12-12 18:35:43 +09:00
|
|
|
/** 필드 키 (백엔드에서 파일 식별용) - 예: specification_file, certification_file, bending_diagram */
|
|
|
|
|
fieldKey?: string;
|
|
|
|
|
/** 파일 ID (같은 field_key 내 여러 파일 구분용) - 0, 1, 2... (없으면 최초 등록, 있으면 덮어쓰기) */
|
|
|
|
|
fileId?: number;
|
2025-12-06 11:36:38 +09:00
|
|
|
/** 인증번호 (certification 타입일 때) */
|
|
|
|
|
certificationNumber?: string;
|
|
|
|
|
/** 인증 시작일 (certification 타입일 때) */
|
|
|
|
|
certificationStartDate?: string;
|
|
|
|
|
/** 인증 종료일 (certification 타입일 때) */
|
|
|
|
|
certificationEndDate?: string;
|
|
|
|
|
/** 절곡 상세 정보 (bending_diagram 타입일 때) */
|
|
|
|
|
bendingDetails?: Array<{
|
|
|
|
|
angle: number;
|
|
|
|
|
length: number;
|
|
|
|
|
type: string;
|
|
|
|
|
}>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 파일 업로드 응답 */
|
|
|
|
|
export interface UploadFileResponse {
|
|
|
|
|
file_type: string;
|
|
|
|
|
file_url: string;
|
|
|
|
|
file_path: string;
|
|
|
|
|
file_name: string;
|
|
|
|
|
product: Record<string, unknown>;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 14:17:52 +09:00
|
|
|
/**
|
2025-12-06 11:36:38 +09:00
|
|
|
* 품목 파일 업로드 (ID 기반, 프록시 사용)
|
2025-11-18 14:17:52 +09:00
|
|
|
*
|
2025-12-06 11:36:38 +09:00
|
|
|
* HttpOnly 쿠키 인증을 위해 Next.js API 프록시를 경유합니다.
|
|
|
|
|
*
|
|
|
|
|
* @param itemId - 품목 ID (숫자)
|
2025-11-18 14:17:52 +09:00
|
|
|
* @param file - 업로드할 파일
|
|
|
|
|
* @param fileType - 파일 유형 (specification, certification, bending_diagram)
|
2025-12-06 11:36:38 +09:00
|
|
|
* @param options - 추가 옵션 (certification 관련 필드, bending_details 등)
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* // 시방서 업로드
|
|
|
|
|
* await uploadItemFile(123, specFile, 'specification');
|
|
|
|
|
*
|
|
|
|
|
* // 인정서 업로드 (추가 정보 포함)
|
|
|
|
|
* await uploadItemFile(123, certFile, 'certification', {
|
|
|
|
|
* certificationNumber: 'CERT-001',
|
|
|
|
|
* certificationStartDate: '2025-01-01',
|
|
|
|
|
* certificationEndDate: '2026-01-01',
|
|
|
|
|
* });
|
|
|
|
|
*
|
|
|
|
|
* // 절곡/조립 전개도 업로드
|
|
|
|
|
* await uploadItemFile(123, diagramFile, 'bending_diagram');
|
|
|
|
|
*/
|
|
|
|
|
export async function uploadItemFile(
|
|
|
|
|
itemId: number,
|
|
|
|
|
file: File,
|
|
|
|
|
fileType: ItemFileType,
|
|
|
|
|
options?: UploadFileOptions
|
|
|
|
|
): Promise<UploadFileResponse> {
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append('file', file);
|
|
|
|
|
formData.append('type', fileType);
|
|
|
|
|
|
2025-12-12 18:35:43 +09:00
|
|
|
// 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));
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 11:36:38 +09:00
|
|
|
// certification 관련 추가 필드
|
|
|
|
|
if (fileType === 'certification' && options) {
|
|
|
|
|
if (options.certificationNumber) {
|
|
|
|
|
formData.append('certification_number', options.certificationNumber);
|
|
|
|
|
}
|
|
|
|
|
if (options.certificationStartDate) {
|
|
|
|
|
formData.append('certification_start_date', options.certificationStartDate);
|
|
|
|
|
}
|
|
|
|
|
if (options.certificationEndDate) {
|
|
|
|
|
formData.append('certification_end_date', options.certificationEndDate);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// bending_diagram 관련 추가 필드
|
2025-12-12 18:35:43 +09:00
|
|
|
// 백엔드가 배열 형태를 기대하므로 각 항목을 개별적으로 append
|
2025-12-06 11:36:38 +09:00
|
|
|
if (fileType === 'bending_diagram' && options?.bendingDetails) {
|
2025-12-12 18:35:43 +09:00
|
|
|
options.bendingDetails.forEach((detail, index) => {
|
|
|
|
|
Object.entries(detail).forEach(([key, value]) => {
|
|
|
|
|
formData.append(`bending_details[${index}][${key}]`, String(value));
|
|
|
|
|
});
|
|
|
|
|
});
|
2025-12-06 11:36:38 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 프록시 경유: /api/proxy/items/{id}/files → /api/v1/items/{id}/files
|
|
|
|
|
const response = await fetch(`/api/proxy/items/${itemId}/files`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: formData,
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
// Content-Type은 FormData 사용 시 자동 설정됨 (boundary 포함)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const data = await handleApiResponse<ApiResponse<UploadFileResponse>>(response);
|
|
|
|
|
return data.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 품목 파일 삭제 (ID 기반, 프록시 사용)
|
|
|
|
|
*
|
|
|
|
|
* @param itemId - 품목 ID (숫자)
|
|
|
|
|
* @param fileType - 파일 유형 (specification, certification, bending_diagram)
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* await deleteItemFile(123, 'specification');
|
|
|
|
|
*/
|
|
|
|
|
export async function deleteItemFile(
|
|
|
|
|
itemId: number,
|
|
|
|
|
fileType: ItemFileType
|
|
|
|
|
): Promise<{ file_type: string; deleted: boolean; product: Record<string, unknown> }> {
|
|
|
|
|
// 프록시 경유: /api/proxy/items/{id}/files/{type} → /api/v1/items/{id}/files/{type}
|
|
|
|
|
const response = await fetch(`/api/proxy/items/${itemId}/files/${fileType}`, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const data = await handleApiResponse<ApiResponse<{ file_type: string; deleted: boolean; product: Record<string, unknown> }>>(response);
|
|
|
|
|
return data.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 레거시 파일 업로드 (하위 호환성) =====
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @deprecated uploadItemFile 사용 권장 (ID 기반)
|
|
|
|
|
* 파일 업로드 (시방서, 인정서, 전개도 등) - 품목 코드 기반
|
2025-11-18 14:17:52 +09:00
|
|
|
*/
|
|
|
|
|
export async function uploadFile(
|
|
|
|
|
itemCode: string,
|
|
|
|
|
file: File,
|
|
|
|
|
fileType: 'specification' | 'certification' | 'bending_diagram'
|
|
|
|
|
): Promise<{ url: string; filename: string }> {
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append('file', file);
|
|
|
|
|
formData.append('type', fileType);
|
|
|
|
|
|
|
|
|
|
const token = getAuthToken();
|
2025-11-25 21:07:10 +09:00
|
|
|
const headers: Record<string, string> = {};
|
2025-11-18 14:17:52 +09:00
|
|
|
if (token) {
|
|
|
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/files`,
|
|
|
|
|
{
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers,
|
|
|
|
|
body: formData,
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const data = await handleApiResponse<ApiResponse<{ url: string; filename: string }>>(response);
|
|
|
|
|
return data.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-12-06 11:36:38 +09:00
|
|
|
* @deprecated deleteItemFile 사용 권장 (ID 기반)
|
|
|
|
|
* 파일 삭제 - 품목 코드 기반
|
2025-11-18 14:17:52 +09:00
|
|
|
*/
|
|
|
|
|
export async function deleteFile(
|
|
|
|
|
itemCode: string,
|
|
|
|
|
fileType: 'specification' | 'certification' | 'bending_diagram'
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/files/${fileType}`,
|
|
|
|
|
createFetchOptions({
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await handleApiResponse<ApiResponse<null>>(response);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 검색 및 필터 =====
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 품목 코드로 검색 (자동완성용)
|
|
|
|
|
*
|
|
|
|
|
* @param query - 검색어
|
|
|
|
|
* @param limit - 최대 결과 개수
|
|
|
|
|
*/
|
|
|
|
|
export async function searchItemCodes(
|
|
|
|
|
query: string,
|
|
|
|
|
limit: number = 10
|
|
|
|
|
): Promise<string[]> {
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`${API_URL}/api/items/search/codes?q=${encodeURIComponent(query)}&limit=${limit}`,
|
|
|
|
|
createFetchOptions()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const data = await handleApiResponse<ApiResponse<string[]>>(response);
|
|
|
|
|
return data.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 품목명으로 검색 (자동완성용)
|
|
|
|
|
*
|
|
|
|
|
* @param query - 검색어
|
|
|
|
|
* @param limit - 최대 결과 개수
|
|
|
|
|
*/
|
|
|
|
|
export async function searchItemNames(
|
|
|
|
|
query: string,
|
|
|
|
|
limit: number = 10
|
|
|
|
|
): Promise<Array<{ itemCode: string; itemName: string }>> {
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`${API_URL}/api/items/search/names?q=${encodeURIComponent(query)}&limit=${limit}`,
|
|
|
|
|
createFetchOptions()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const data = await handleApiResponse<ApiResponse<Array<{ itemCode: string; itemName: string }>>>(response);
|
|
|
|
|
return data.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 유틸리티 =====
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 품목 코드 중복 체크
|
|
|
|
|
*
|
|
|
|
|
* @param itemCode - 체크할 품목 코드
|
|
|
|
|
* @returns 사용 가능 여부 (true: 사용 가능, false: 중복)
|
|
|
|
|
*/
|
|
|
|
|
export async function checkItemCodeAvailability(
|
|
|
|
|
itemCode: string
|
|
|
|
|
): Promise<boolean> {
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`${API_URL}/api/items/check/${encodeURIComponent(itemCode)}`,
|
|
|
|
|
createFetchOptions()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const data = await handleApiResponse<ApiResponse<{ available: boolean }>>(response);
|
|
|
|
|
return data.data.available;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-12 18:35:43 +09:00
|
|
|
/** 품목 코드 중복 체크 결과 */
|
|
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 14:17:52 +09:00
|
|
|
/**
|
|
|
|
|
* 다음 품목 코드 생성 (서버에서 자동 생성)
|
|
|
|
|
*
|
|
|
|
|
* @param itemType - 품목 유형
|
|
|
|
|
* @returns 생성된 품목 코드 (예: "KD-FG-001")
|
|
|
|
|
*/
|
|
|
|
|
export async function generateItemCode(
|
|
|
|
|
itemType: string
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`${API_URL}/api/items/generate-code?itemType=${itemType}`,
|
|
|
|
|
createFetchOptions()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const data = await handleApiResponse<ApiResponse<{ itemCode: string }>>(response);
|
|
|
|
|
return data.data.itemCode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Next.js revalidation 트리거
|
|
|
|
|
* Server Component에서 데이터 변경 시 호출
|
|
|
|
|
*/
|
|
|
|
|
export async function revalidateItems(): Promise<void> {
|
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
// Server Component에서만 실행
|
|
|
|
|
const { revalidatePath } = await import('next/cache');
|
|
|
|
|
revalidatePath('/items');
|
|
|
|
|
}
|
|
|
|
|
}
|