/** * 품목 관리 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(); const headers: Record = { 'Content-Type': 'application/json', ...(options.headers as Record), }; if (token) { headers['Authorization'] = `Bearer ${token}`; } return { ...options, headers, credentials: 'include', // 쿠키 포함 }; } /** * API 에러 처리 */ async function handleApiResponse(response: Response): Promise { 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); * }, []); */ // API 응답 → 프론트엔드(camelCase) 변환 interface ApiItemResponse { id?: number | string; // DB 필드명 (code, name) code?: string; name?: string; item_type?: string; unit?: string; specification?: string; is_active?: boolean | number; // snake_case 대안 (item_code, item_name) item_code?: string; item_name?: string; // camelCase도 지원 (이미 변환된 경우) itemCode?: string; itemName?: string; itemType?: string; isActive?: boolean; } function transformItemFromApi(apiItem: ApiItemResponse): ItemMaster { return { id: String(apiItem.id || ''), // 우선순위: code > item_code > itemCode itemCode: apiItem.code || apiItem.item_code || apiItem.itemCode || '', // 우선순위: name > item_name > itemName itemName: apiItem.name || apiItem.item_name || apiItem.itemName || '', itemType: (apiItem.item_type || apiItem.itemType || 'FG') as ItemMaster['itemType'], unit: apiItem.unit || '', specification: apiItem.specification || '', isActive: apiItem.is_active === true || apiItem.is_active === 1 || apiItem.isActive === true, }; } export async function fetchItems( params?: FetchItemsParams ): Promise { const queryParams = new URLSearchParams(); if (params) { Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null) { queryParams.append(key, String(value)); } }); } // 클라이언트에서 호출 시 프록시 라우트 사용 (HttpOnly 쿠키 인증 지원) const isClient = typeof window !== 'undefined'; const baseUrl = isClient ? '/api/proxy' : `${API_URL}/api/v1`; const url = `${baseUrl}/items${queryParams.toString() ? `?${queryParams}` : ''}`; const response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', }, credentials: 'include', }); if (!response.ok) { throw new Error(`품목 조회 실패: ${response.status}`); } const result = await response.json(); // 디버깅: API 응답 구조 확인 console.log('[fetchItems] API 응답:', JSON.stringify(result, null, 2).slice(0, 1000)); // 데이터 추출 let rawItems: ApiItemResponse[] = []; if (result.success && result.data) { // 페이지네이션 응답: { success: true, data: { data: [...], ... } } if (result.data.data && Array.isArray(result.data.data)) { rawItems = result.data.data; } // 단순 배열 응답: { success: true, data: [...] } else if (Array.isArray(result.data)) { rawItems = result.data; } } // 직접 배열 응답 (fallback) else if (Array.isArray(result)) { rawItems = result; } // 디버깅: rawItems 첫 번째 항목 확인 if (rawItems.length > 0) { console.log('[fetchItems] rawItems[0]:', rawItems[0]); } // snake_case → camelCase 변환 const transformed = rawItems.map(transformItemFromApi); // 디버깅: 변환된 첫 번째 항목 확인 if (transformed.length > 0) { console.log('[fetchItems] transformed[0]:', transformed[0]); } return transformed; } /** * 품목 목록 조회 (페이지네이션) */ export async function fetchItemsPaginated( params?: FetchItemsParams ): Promise> { 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>>(response); return data.data; } /** * 품목 상세 조회 * * @param itemCode - 품목 코드 (예: "KD-FG-001") * * @example * const item = await fetchItemByCode('KD-FG-001'); */ export async function fetchItemByCode(itemCode: string): Promise { const response = await fetch( `${API_URL}/api/items/${encodeURIComponent(itemCode)}`, createFetchOptions() ); const data = await handleApiResponse>(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 { const response = await fetch( `${API_URL}/api/items`, createFetchOptions({ method: 'POST', body: JSON.stringify(itemData), }) ); const data = await handleApiResponse>(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 { const response = await fetch( `${API_URL}/api/items/${encodeURIComponent(itemCode)}`, createFetchOptions({ method: 'PUT', body: JSON.stringify(updates), }) ); const data = await handleApiResponse>(response); return data.data; } /** * 품목 삭제 * * @param itemCode - 품목 코드 * * @example * await deleteItem('KD-FG-001'); */ export async function deleteItem(itemCode: string): Promise { const response = await fetch( `${API_URL}/api/items/${encodeURIComponent(itemCode)}`, createFetchOptions({ method: 'DELETE', }) ); await handleApiResponse>(response); } // ===== BOM (자재명세서) 관리 ===== /** * BOM 목록 조회 * * @param itemCode - 상위 품목 코드 */ export async function fetchBOM(itemCode: string): Promise { const response = await fetch( `${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom`, createFetchOptions() ); const data = await handleApiResponse>(response); return data.data; } /** * BOM 계층구조 조회 (트리 형태) * * @param itemCode - 상위 품목 코드 */ export async function fetchBOMTree(itemCode: string): Promise { const response = await fetch( `${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom/tree`, createFetchOptions() ); const data = await handleApiResponse>(response); return data.data; } /** * BOM 라인 추가 * * @param itemCode - 상위 품목 코드 * @param bomLine - BOM 라인 데이터 */ export async function addBOMLine( itemCode: string, bomLine: Omit ): Promise { const response = await fetch( `${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom`, createFetchOptions({ method: 'POST', body: JSON.stringify(bomLine), }) ); const data = await handleApiResponse>(response); return data.data; } /** * BOM 라인 수정 * * @param itemCode - 상위 품목 코드 * @param lineId - BOM 라인 ID * @param updates - 수정할 필드들 */ export async function updateBOMLine( itemCode: string, lineId: string, updates: Partial ): Promise { const response = await fetch( `${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom/${lineId}`, createFetchOptions({ method: 'PUT', body: JSON.stringify(updates), }) ); const data = await handleApiResponse>(response); return data.data; } /** * BOM 라인 삭제 * * @param itemCode - 상위 품목 코드 * @param lineId - BOM 라인 ID */ export async function deleteBOMLine( itemCode: string, lineId: string ): Promise { const response = await fetch( `${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom/${lineId}`, createFetchOptions({ method: 'DELETE', }) ); await handleApiResponse>(response); } // ===== 파일 업로드 ===== /** 파일 타입 */ 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 타입일 때) */ 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; } /** * 품목 파일 업로드 (ID 기반, 프록시 사용) * * HttpOnly 쿠키 인증을 위해 Next.js API 프록시를 경유합니다. * * @param itemId - 품목 ID (숫자) * @param file - 업로드할 파일 * @param fileType - 파일 유형 (specification, certification, bending_diagram) * @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 { const formData = new FormData(); 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) { 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 관련 추가 필드 // 백엔드가 배열 형태를 기대하므로 각 항목을 개별적으로 append if (fileType === 'bending_diagram' && 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 const response = await fetch(`/api/proxy/items/${itemId}/files`, { method: 'POST', body: formData, credentials: 'include', // Content-Type은 FormData 사용 시 자동 설정됨 (boundary 포함) }); const data = await handleApiResponse>(response); return data.data; } /** * 품목 파일 삭제 (파일 ID 기반, 프록시 사용) * * @param itemId - 품목 ID (숫자) * @param fileId - 파일 ID (files 테이블의 id) * @param itemType - 품목 유형 (FG, PT, SM 등) - 기본값 'FG' * * @example * await deleteItemFile(123, 456, 'FG'); */ export async function deleteItemFile( itemId: number, fileId: number, itemType: string = 'FG' ): Promise<{ file_id: number; deleted: boolean }> { // 프록시 경유: /api/proxy/items/{id}/files/{fileId} → /api/v1/items/{id}/files/{fileId} const response = await fetch(`/api/proxy/items/${itemId}/files/${fileId}?item_type=${itemType}`, { method: 'DELETE', credentials: 'include', }); const data = await handleApiResponse>(response); return data.data; } // ===== 레거시 파일 업로드 (하위 호환성) ===== /** * @deprecated uploadItemFile 사용 권장 (ID 기반) * 파일 업로드 (시방서, 인정서, 전개도 등) - 품목 코드 기반 */ 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(); const headers: Record = {}; 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>(response); return data.data; } /** * @deprecated deleteItemFile 사용 권장 (ID 기반) * 파일 삭제 - 품목 코드 기반 */ export async function deleteFile( itemCode: string, fileType: 'specification' | 'certification' | 'bending_diagram' ): Promise { const response = await fetch( `${API_URL}/api/items/${encodeURIComponent(itemCode)}/files/${fileType}`, createFetchOptions({ method: 'DELETE', }) ); await handleApiResponse>(response); } // ===== 검색 및 필터 ===== /** * 품목 코드로 검색 (자동완성용) * * @param query - 검색어 * @param limit - 최대 결과 개수 */ export async function searchItemCodes( query: string, limit: number = 10 ): Promise { const response = await fetch( `${API_URL}/api/items/search/codes?q=${encodeURIComponent(query)}&limit=${limit}`, createFetchOptions() ); const data = await handleApiResponse>(response); return data.data; } /** * 품목명으로 검색 (자동완성용) * * @param query - 검색어 * @param limit - 최대 결과 개수 */ export async function searchItemNames( query: string, limit: number = 10 ): Promise> { const response = await fetch( `${API_URL}/api/items/search/names?q=${encodeURIComponent(query)}&limit=${limit}`, createFetchOptions() ); const data = await handleApiResponse>>(response); return data.data; } // ===== 유틸리티 ===== /** * 품목 코드 중복 체크 * * @param itemCode - 체크할 품목 코드 * @returns 사용 가능 여부 (true: 사용 가능, false: 중복) */ export async function checkItemCodeAvailability( itemCode: string ): Promise { const response = await fetch( `${API_URL}/api/items/check/${encodeURIComponent(itemCode)}`, createFetchOptions() ); const data = await handleApiResponse>(response); 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 { 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 }; } } /** * 다음 품목 코드 생성 (서버에서 자동 생성) * * @param itemType - 품목 유형 * @returns 생성된 품목 코드 (예: "KD-FG-001") */ export async function generateItemCode( itemType: string ): Promise { const response = await fetch( `${API_URL}/api/items/generate-code?itemType=${itemType}`, createFetchOptions() ); const data = await handleApiResponse>(response); return data.data.itemCode; } /** * Next.js revalidation 트리거 * Server Component에서 데이터 변경 시 호출 */ export async function revalidateItems(): Promise { if (typeof window === 'undefined') { // Server Component에서만 실행 const { revalidatePath } = await import('next/cache'); revalidatePath('/items'); } } // ===== 품목 단가 조회 ===== /** 품목 단가 조회 결과 */ export interface ItemPriceResult { item_code: string; unit_price: number; } /** * 품목 단가 조회 (견적 품목 추가 시 사용) * * @param itemCodes - 조회할 품목 코드 배열 * @returns 품목 코드별 단가 정보 * * @example * const prices = await fetchItemPrices(['N71807', 'N71808']); * console.log(prices['N71807']?.unit_price); // 1320000 */ export async function fetchItemPrices( itemCodes: string[] ): Promise> { if (!itemCodes.length) { return {}; } try { // 프록시 경유: /api/proxy/quotes/items/prices → /api/v1/quotes/items/prices const response = await fetch('/api/proxy/quotes/items/prices', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ item_codes: itemCodes }), credentials: 'include', }); if (!response.ok) { console.error('[fetchItemPrices] API 에러:', response.status); return {}; } const result = await response.json(); console.log('[fetchItemPrices] API 응답:', result); if (result.success && result.data) { return result.data as Record; } return {}; } catch (error) { console.error('[fetchItemPrices] 에러:', error); return {}; } }