feat: 견적 V2 품목 검색 API 연동 및 수동 품목 관리 개선
- ItemSearchModal: API 프록시 라우트 연동, 검색 유효성 검사 (영문/한글 1자 이상) - items.ts: HttpOnly 쿠키 인증을 위한 프록시 라우트 사용 - LocationDetailPanel: 수동 품목 추가 시 subtotals/grouped_items 동시 업데이트 - QuoteRegistrationV2: 견적 산출 시 수동 추가 품목(is_manual) 보존 - 상세별 합계에서 수동 추가 품목이 카테고리별로 표시되도록 개선
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* 품목 검색 모달
|
||||
*
|
||||
* - 품목 코드로 검색
|
||||
* - 품목 코드/이름으로 검색
|
||||
* - 품목 목록에서 선택
|
||||
* - API 연동
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { Search, X, Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@@ -17,17 +18,8 @@ import {
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
import { Input } from "../ui/input";
|
||||
|
||||
// =============================================================================
|
||||
// 목데이터 - 품목 목록
|
||||
// =============================================================================
|
||||
|
||||
const MOCK_ITEMS = [
|
||||
{ code: "KSS01", name: "스크린", description: "방화스크린 기본형" },
|
||||
{ code: "KSS02", name: "스크린", description: "방화스크린 고급형" },
|
||||
{ code: "KSS03", name: "슬랫", description: "방화슬랫 기본형" },
|
||||
{ code: "KSS04", name: "스크린", description: "방화스크린 특수형" },
|
||||
];
|
||||
import { fetchItems } from "@/lib/api/items";
|
||||
import type { ItemMaster, ItemType } from "@/types/item";
|
||||
|
||||
// =============================================================================
|
||||
// Props
|
||||
@@ -36,8 +28,10 @@ const MOCK_ITEMS = [
|
||||
interface ItemSearchModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelectItem: (item: { code: string; name: string }) => void;
|
||||
onSelectItem: (item: { code: string; name: string; specification?: string }) => void;
|
||||
tabLabel?: string;
|
||||
/** 품목 유형 필터 (예: 'RM', 'SF', 'FG') */
|
||||
itemType?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -49,42 +43,103 @@ export function ItemSearchModal({
|
||||
onOpenChange,
|
||||
onSelectItem,
|
||||
tabLabel,
|
||||
itemType,
|
||||
}: ItemSearchModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [items, setItems] = useState<ItemMaster[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 검색 필터링
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!searchQuery) return MOCK_ITEMS;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return MOCK_ITEMS.filter(
|
||||
(item) =>
|
||||
item.code.toLowerCase().includes(query) ||
|
||||
item.name.toLowerCase().includes(query) ||
|
||||
item.description.toLowerCase().includes(query)
|
||||
);
|
||||
}, [searchQuery]);
|
||||
// 품목 목록 조회
|
||||
const loadItems = useCallback(async (search?: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchItems({
|
||||
search: search || undefined,
|
||||
itemType: itemType as ItemType | undefined,
|
||||
per_page: 50,
|
||||
});
|
||||
setItems(data);
|
||||
} catch (err) {
|
||||
console.error("[ItemSearchModal] 품목 조회 오류:", err);
|
||||
setError("품목 목록을 불러오는데 실패했습니다.");
|
||||
setItems([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [itemType]);
|
||||
|
||||
const handleSelect = (item: (typeof MOCK_ITEMS)[0]) => {
|
||||
onSelectItem({ code: item.code, name: item.name });
|
||||
// 검색어 유효성 검사: 영문 1자 이상 또는 한글 1자 이상
|
||||
const isValidSearchQuery = useCallback((query: string) => {
|
||||
if (!query) return false;
|
||||
// 영문 1자 이상 또는 한글 1자 이상
|
||||
const hasEnglish = /[a-zA-Z]/.test(query);
|
||||
const hasKorean = /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(query);
|
||||
return hasEnglish || hasKorean;
|
||||
}, []);
|
||||
|
||||
// 모달 열릴 때 초기화 (자동 로드 안함)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setItems([]);
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 검색어 변경 시 디바운스 검색 (유효한 검색어만)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
// 검색어가 유효하지 않으면 결과 초기화
|
||||
if (!isValidSearchQuery(searchQuery)) {
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
loadItems(searchQuery);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, open, loadItems, isValidSearchQuery]);
|
||||
|
||||
// 검색 결과 그대로 사용 (서버에서 이미 필터링됨)
|
||||
const filteredItems = items;
|
||||
|
||||
const handleSelect = (item: ItemMaster) => {
|
||||
onSelectItem({
|
||||
code: item.itemCode,
|
||||
name: item.itemName,
|
||||
specification: item.specification || undefined,
|
||||
});
|
||||
onOpenChange(false);
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>품목 검색</DialogTitle>
|
||||
<DialogTitle>
|
||||
품목 검색
|
||||
{tabLabel && <span className="text-sm font-normal text-gray-500 ml-2">({tabLabel})</span>}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="원하는 검색어..."
|
||||
placeholder="품목코드 또는 품목명 검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
@@ -97,31 +152,58 @@ export function ItemSearchModal({
|
||||
</div>
|
||||
|
||||
{/* 품목 목록 */}
|
||||
<div className="max-h-[300px] overflow-y-auto border rounded-lg divide-y">
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="max-h-[400px] overflow-y-auto border rounded-lg">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8 text-gray-500">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||
<span>품목 검색 중...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-center text-red-500 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">
|
||||
검색 결과가 없습니다
|
||||
{!searchQuery
|
||||
? "품목코드 또는 품목명을 입력하세요"
|
||||
: !isValidSearchQuery(searchQuery)
|
||||
? "영문 또는 한글 1자 이상 입력하세요"
|
||||
: "검색 결과가 없습니다"}
|
||||
</div>
|
||||
) : (
|
||||
filteredItems.map((item) => (
|
||||
<div
|
||||
key={item.code}
|
||||
onClick={() => handleSelect(item)}
|
||||
className="p-3 hover:bg-blue-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-900">{item.code}</span>
|
||||
<span className="ml-2 text-sm text-gray-500">{item.name}</span>
|
||||
<div className="divide-y">
|
||||
{filteredItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id ?? `${item.itemCode}-${index}`}
|
||||
onClick={() => handleSelect(item)}
|
||||
className="p-3 hover:bg-blue-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-900">{item.itemCode}</span>
|
||||
<span className="ml-2 text-sm text-gray-600">{item.itemName}</span>
|
||||
</div>
|
||||
{item.unit && (
|
||||
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
|
||||
{item.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.specification && (
|
||||
<p className="text-xs text-gray-400 mt-1">{item.specification}</p>
|
||||
)}
|
||||
</div>
|
||||
{item.description && (
|
||||
<p className="text-xs text-gray-400 mt-1">{item.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 품목 개수 표시 */}
|
||||
{!isLoading && !error && (
|
||||
<div className="text-xs text-gray-400 text-right">
|
||||
총 {filteredItems.length}개 품목
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -618,7 +618,91 @@ export function LocationDetailPanel({
|
||||
open={itemSearchOpen}
|
||||
onOpenChange={setItemSearchOpen}
|
||||
onSelectItem={(item) => {
|
||||
console.log(`[테스트] 품목 선택: ${item.code} - ${item.name} (탭: ${activeTab})`);
|
||||
if (!location) return;
|
||||
|
||||
// 탭 → process_group_key 매핑
|
||||
const tabToProcessGroup: Record<string, string> = {
|
||||
body: "screen",
|
||||
"guide-rail": "bending",
|
||||
case: "steel",
|
||||
bottom: "electric",
|
||||
motor: "motor",
|
||||
accessory: "accessory",
|
||||
};
|
||||
|
||||
const processGroupKey = tabToProcessGroup[activeTab] || "screen";
|
||||
const processGroupLabel = DETAIL_TABS.find((t) => t.value === activeTab)?.label || activeTab;
|
||||
|
||||
// 새 품목 생성 (수동 추가 플래그 포함)
|
||||
const newItem: BomCalculationResultItem & { process_group_key?: string; is_manual?: boolean } = {
|
||||
item_code: item.code,
|
||||
item_name: item.name,
|
||||
specification: item.specification || "",
|
||||
unit: "EA",
|
||||
quantity: 1,
|
||||
unit_price: 0,
|
||||
total_price: 0,
|
||||
process_group: processGroupLabel,
|
||||
process_group_key: processGroupKey,
|
||||
is_manual: true, // 수동 추가 품목 표시
|
||||
};
|
||||
|
||||
// 기존 bomResult 가져오기
|
||||
const existingBomResult = location.bomResult || {
|
||||
finished_goods: { code: location.productCode || "", name: location.productName || "" },
|
||||
subtotals: {},
|
||||
grouped_items: {},
|
||||
grand_total: 0,
|
||||
items: [],
|
||||
};
|
||||
|
||||
// 기존 items에 새 아이템 추가
|
||||
const existingItems = existingBomResult.items || [];
|
||||
const updatedItems = [...existingItems, newItem];
|
||||
|
||||
// subtotals 업데이트 (해당 카테고리의 count, subtotal 증가)
|
||||
const existingSubtotals = existingBomResult.subtotals || {};
|
||||
const categorySubtotal = existingSubtotals[processGroupKey] || {
|
||||
name: processGroupLabel,
|
||||
count: 0,
|
||||
subtotal: 0,
|
||||
};
|
||||
const updatedSubtotals = {
|
||||
...existingSubtotals,
|
||||
[processGroupKey]: {
|
||||
...categorySubtotal,
|
||||
name: processGroupLabel,
|
||||
count: (categorySubtotal.count || 0) + 1,
|
||||
subtotal: (categorySubtotal.subtotal || 0) + (newItem.total_price || 0),
|
||||
},
|
||||
};
|
||||
|
||||
// grouped_items 업데이트 (해당 카테고리의 items 배열에 추가)
|
||||
const existingGroupedItems = existingBomResult.grouped_items || {};
|
||||
const categoryGroupedItems = existingGroupedItems[processGroupKey] || { items: [] };
|
||||
const updatedGroupedItems = {
|
||||
...existingGroupedItems,
|
||||
[processGroupKey]: {
|
||||
...categoryGroupedItems,
|
||||
items: [...(categoryGroupedItems.items || []), newItem],
|
||||
},
|
||||
};
|
||||
|
||||
// grand_total 업데이트
|
||||
const updatedGrandTotal = (existingBomResult.grand_total || 0) + (newItem.total_price || 0);
|
||||
|
||||
const updatedBomResult = {
|
||||
...existingBomResult,
|
||||
items: updatedItems,
|
||||
subtotals: updatedSubtotals,
|
||||
grouped_items: updatedGroupedItems,
|
||||
grand_total: updatedGrandTotal,
|
||||
};
|
||||
|
||||
// location 업데이트
|
||||
onUpdateLocation(location.id, { bomResult: updatedBomResult });
|
||||
|
||||
console.log(`[품목 추가] ${item.code} - ${item.name} → ${processGroupLabel} (${processGroupKey})`);
|
||||
}}
|
||||
tabLabel={DETAIL_TABS.find((t) => t.value === activeTab)?.label}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { Download, Save, Check, ArrowLeft, Loader2, Calculator, Eye } from "lucide-react";
|
||||
import { Download, Save, Check, ArrowLeft, Loader2, Calculator, Eye, Pencil, ClipboardList } from "lucide-react";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
@@ -25,9 +25,13 @@ interface QuoteFooterBarProps {
|
||||
onSaveTemporary: () => void;
|
||||
onSaveFinal: () => void;
|
||||
onBack: () => void;
|
||||
onEdit?: () => void;
|
||||
onOrderRegister?: () => void;
|
||||
isCalculating?: boolean;
|
||||
isSaving?: boolean;
|
||||
disabled?: boolean;
|
||||
/** view 모드 여부 (view: 수정+최종저장, edit: 임시저장+최종저장) */
|
||||
isViewMode?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -43,9 +47,12 @@ export function QuoteFooterBar({
|
||||
onSaveTemporary,
|
||||
onSaveFinal,
|
||||
onBack,
|
||||
onEdit,
|
||||
onOrderRegister,
|
||||
isCalculating = false,
|
||||
isSaving = false,
|
||||
disabled = false,
|
||||
isViewMode = false,
|
||||
}: QuoteFooterBarProps) {
|
||||
return (
|
||||
<div className="sticky bottom-0 bg-gradient-to-r from-blue-50 to-indigo-50 border-t border-blue-200 shadow-lg">
|
||||
@@ -72,29 +79,31 @@ export function QuoteFooterBar({
|
||||
|
||||
{/* 오른쪽: 버튼들 */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 견적서 산출 */}
|
||||
<Button
|
||||
onClick={onCalculate}
|
||||
disabled={disabled || isCalculating || totalLocations === 0}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white gap-2 px-6"
|
||||
>
|
||||
{isCalculating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
산출 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Calculator className="h-4 w-4" />
|
||||
견적서 산출
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{/* 견적서 산출 - edit 모드에서만 활성화 */}
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
onClick={onCalculate}
|
||||
disabled={isCalculating || totalLocations === 0}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white gap-2 px-6"
|
||||
>
|
||||
{isCalculating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
산출 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Calculator className="h-4 w-4" />
|
||||
견적서 산출
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 미리보기 */}
|
||||
<Button
|
||||
onClick={onPreview}
|
||||
disabled={disabled || totalLocations === 0}
|
||||
disabled={totalLocations === 0}
|
||||
variant="outline"
|
||||
className="gap-2 px-6"
|
||||
>
|
||||
@@ -102,33 +111,60 @@ export function QuoteFooterBar({
|
||||
미리보기
|
||||
</Button>
|
||||
|
||||
{/* 임시저장 */}
|
||||
<Button
|
||||
onClick={onSaveTemporary}
|
||||
disabled={disabled || isSaving}
|
||||
className="bg-slate-500 hover:bg-slate-600 text-white gap-2 px-6"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
임시저장
|
||||
</Button>
|
||||
{/* 수정 - view 모드에서만 표시 */}
|
||||
{isViewMode && onEdit && (
|
||||
<Button
|
||||
onClick={onEdit}
|
||||
variant="outline"
|
||||
className="gap-2 px-6"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 최종저장 */}
|
||||
<Button
|
||||
onClick={onSaveFinal}
|
||||
disabled={disabled || isSaving || totalAmount === 0}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white gap-2 px-6"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
최종저장
|
||||
</Button>
|
||||
{/* 임시저장 - edit 모드에서만 표시 */}
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
onClick={onSaveTemporary}
|
||||
disabled={isSaving}
|
||||
className="bg-slate-500 hover:bg-slate-600 text-white gap-2 px-6"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
임시저장
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 최종저장 - 양쪽 모드에서 표시 (final 상태가 아닐 때만) */}
|
||||
{status !== "final" && (
|
||||
<Button
|
||||
onClick={onSaveFinal}
|
||||
disabled={isSaving || totalAmount === 0}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white gap-2 px-6"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
최종저장
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 수주등록 - final 상태일 때 표시 */}
|
||||
{status === "final" && onOrderRegister && (
|
||||
<Button
|
||||
onClick={onOrderRegister}
|
||||
className="bg-green-600 hover:bg-green-700 text-white gap-2 px-6"
|
||||
>
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
수주등록
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@ import { isNextRedirectError } from "@/lib/utils/redirect-error";
|
||||
// 실제 로그인 사용자 정보는 localStorage('user')에 저장됨 (LoginPage.tsx 참조)
|
||||
import { useDevFill } from "@/components/dev/useDevFill";
|
||||
import type { Vendor } from "../accounting/VendorManagement";
|
||||
import type { BomMaterial, CalculationResults } from "./types";
|
||||
import type { BomMaterial, CalculationResults, BomCalculationResultItem } from "./types";
|
||||
import { getLocalDateString, getDateAfterDays } from "@/utils/date";
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -21,10 +21,10 @@ export const quoteConfig: DetailConfig = {
|
||||
fields: [], // renderView/renderForm 사용으로 필드 정의 불필요
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showBack: false, // QuoteFooterBar에서 처리
|
||||
showDelete: false, // QuoteFooterBar에서 처리
|
||||
showEdit: false, // QuoteFooterBar에서 처리
|
||||
backLabel: '목록',
|
||||
showSave: false, // QuoteFooterBar에서 처리
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user