feat: 견적 V2 품목 검색 API 연동 및 수동 품목 관리 개선

- ItemSearchModal: API 프록시 라우트 연동, 검색 유효성 검사 (영문/한글 1자 이상)
- items.ts: HttpOnly 쿠키 인증을 위한 프록시 라우트 사용
- LocationDetailPanel: 수동 품목 추가 시 subtotals/grouped_items 동시 업데이트
- QuoteRegistrationV2: 견적 산출 시 수동 추가 품목(is_manual) 보존
- 상세별 합계에서 수동 추가 품목이 카테고리별로 표시되도록 개선
This commit is contained in:
2026-01-27 14:28:17 +09:00
parent 815ed9267e
commit 05fd5b32f2
6 changed files with 389 additions and 105 deletions

View File

@@ -86,6 +86,40 @@ async function handleApiResponse<T>(response: Response): Promise<T> {
* 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<ItemMaster[]> {
@@ -99,12 +133,60 @@ export async function fetchItems(
});
}
const url = `${API_URL}/api/items${queryParams.toString() ? `?${queryParams}` : ''}`;
// 클라이언트에서 호출 시 프록시 라우트 사용 (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, createFetchOptions());
const data = await handleApiResponse<ApiResponse<ItemMaster[]>>(response);
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
return data.data;
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;
}
/**