From 05fd5b32f227f1e1445dc0a4f5c115eea85b9934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 27 Jan 2026 14:28:17 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=AC=EC=A0=81=20V2=20=ED=92=88?= =?UTF-8?q?=EB=AA=A9=20=EA=B2=80=EC=83=89=20API=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EB=B0=8F=20=EC=88=98=EB=8F=99=20=ED=92=88=EB=AA=A9=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ItemSearchModal: API 프록시 라우트 연동, 검색 유효성 검사 (영문/한글 1자 이상) - items.ts: HttpOnly 쿠키 인증을 위한 프록시 라우트 사용 - LocationDetailPanel: 수동 품목 추가 시 subtotals/grouped_items 동시 업데이트 - QuoteRegistrationV2: 견적 산출 시 수동 추가 품목(is_manual) 보존 - 상세별 합계에서 수동 추가 품목이 카테고리별로 표시되도록 개선 --- src/components/quotes/ItemSearchModal.tsx | 184 +++++++++++++----- src/components/quotes/LocationDetailPanel.tsx | 86 +++++++- src/components/quotes/QuoteFooterBar.tsx | 128 +++++++----- src/components/quotes/QuoteRegistrationV2.tsx | 2 +- src/components/quotes/quoteConfig.ts | 4 +- src/lib/api/items.ts | 90 ++++++++- 6 files changed, 389 insertions(+), 105 deletions(-) diff --git a/src/components/quotes/ItemSearchModal.tsx b/src/components/quotes/ItemSearchModal.tsx index 73fa5bba..e2b46bb2 100644 --- a/src/components/quotes/ItemSearchModal.tsx +++ b/src/components/quotes/ItemSearchModal.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 ( - - + + - 품목 검색 + + 품목 검색 + {tabLabel && ({tabLabel})} + {/* 검색 입력 */}
setSearchQuery(e.target.value)} - className="pl-10" + className="pl-10 pr-10" /> {searchQuery && (
); diff --git a/src/components/quotes/LocationDetailPanel.tsx b/src/components/quotes/LocationDetailPanel.tsx index 36835710..47bcfd43 100644 --- a/src/components/quotes/LocationDetailPanel.tsx +++ b/src/components/quotes/LocationDetailPanel.tsx @@ -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 = { + 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} /> diff --git a/src/components/quotes/QuoteFooterBar.tsx b/src/components/quotes/QuoteFooterBar.tsx index 26efc771..22c61eed 100644 --- a/src/components/quotes/QuoteFooterBar.tsx +++ b/src/components/quotes/QuoteFooterBar.tsx @@ -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 (
@@ -72,29 +79,31 @@ export function QuoteFooterBar({ {/* 오른쪽: 버튼들 */}
- {/* 견적서 산출 */} - + {/* 견적서 산출 - edit 모드에서만 활성화 */} + {!isViewMode && ( + + )} {/* 미리보기 */} - {/* 임시저장 */} - + {/* 수정 - view 모드에서만 표시 */} + {isViewMode && onEdit && ( + + )} - {/* 최종저장 */} - + {/* 임시저장 - edit 모드에서만 표시 */} + {!isViewMode && ( + + )} + + {/* 최종저장 - 양쪽 모드에서 표시 (final 상태가 아닐 때만) */} + {status !== "final" && ( + + )} + + {/* 수주등록 - final 상태일 때 표시 */} + {status === "final" && onOrderRegister && ( + + )}
diff --git a/src/components/quotes/QuoteRegistrationV2.tsx b/src/components/quotes/QuoteRegistrationV2.tsx index 306f52f8..50728e23 100644 --- a/src/components/quotes/QuoteRegistrationV2.tsx +++ b/src/components/quotes/QuoteRegistrationV2.tsx @@ -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"; // ============================================================================= diff --git a/src/components/quotes/quoteConfig.ts b/src/components/quotes/quoteConfig.ts index ad5197ca..feefd850 100644 --- a/src/components/quotes/quoteConfig.ts +++ b/src/components/quotes/quoteConfig.ts @@ -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에서 처리 }, }; diff --git a/src/lib/api/items.ts b/src/lib/api/items.ts index ab0c08d7..090748ea 100644 --- a/src/lib/api/items.ts +++ b/src/lib/api/items.ts @@ -86,6 +86,40 @@ async function handleApiResponse(response: Response): Promise { * 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 { @@ -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>(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; } /**