From a486977b809a7b5a878c4d3071f206e12b37dda2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 30 Jan 2026 11:23:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EB=B0=9C=EC=A3=BC=EC=B2=98=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EB=AA=A8=EB=8B=AC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=AC=EC=A0=81=20=ED=95=A0=EC=9D=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SupplierSearchModal: 매입 가능 거래처 검색 모달 신규 생성 - QuoteRegistrationV2: 할인율/할인금액을 formData로 통합하여 저장/로드 연동 - QuoteFooterBar: view 모드에서 할인 버튼 비활성화 - types.ts: discountRate/discountAmount 필드 추가, 할인 반영 총액 계산 수정 - quote-management page: 저장 실패 시 에러 메시지 정확히 표시하도록 throw 방식 변경 --- .../sales/quote-management/[id]/page.tsx | 6 +- .../SupplierSearchModal.tsx | 268 ++++++++++++++++++ src/components/quotes/QuoteFooterBar.tsx | 3 +- src/components/quotes/QuoteRegistrationV2.tsx | 15 +- src/components/quotes/types.ts | 21 +- 5 files changed, 297 insertions(+), 16 deletions(-) create mode 100644 src/components/material/ReceivingManagement/SupplierSearchModal.tsx diff --git a/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx b/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx index b9ca4c34..79e684e2 100644 --- a/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx @@ -142,8 +142,7 @@ export default function QuoteDetailPage() { const updateResult = await updateQuote(quoteId, apiData); if (!updateResult.success) { - toast.error(updateResult.error || "저장 중 오류가 발생했습니다."); - return; + throw new Error(updateResult.error || "저장 중 오류가 발생했습니다."); } // 2. 견적 확정인 경우 finalize API 호출 (status 변경은 여기서 처리) @@ -151,8 +150,7 @@ export default function QuoteDetailPage() { const finalizeResult = await finalizeQuote(quoteId); if (!finalizeResult.success) { - toast.error(finalizeResult.error || "견적 확정 중 오류가 발생했습니다."); - return; + throw new Error(finalizeResult.error || "견적 확정 중 오류가 발생했습니다."); } toast.success("견적이 확정되었습니다."); diff --git a/src/components/material/ReceivingManagement/SupplierSearchModal.tsx b/src/components/material/ReceivingManagement/SupplierSearchModal.tsx new file mode 100644 index 00000000..467fb9ba --- /dev/null +++ b/src/components/material/ReceivingManagement/SupplierSearchModal.tsx @@ -0,0 +1,268 @@ +/** + * 발주처(매입 거래처) 검색 모달 + * + * - 거래처명으로 검색 + * - 매입 가능 거래처만 표시 (client_type: PURCHASE, BOTH) + * - ItemSearchModal과 동일한 Dialog + 클라이언트 프록시 패턴 + * - 최소 입력 조건: 한글 완성형 1자 또는 영문 2자 이상 + */ + +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Search, X, Loader2 } from 'lucide-react'; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; + +// ============================================================================= +// 타입 +// ============================================================================= + +interface SupplierItem { + id: number | string; + name: string; + clientCode?: string; + clientType?: string; + contactPerson?: string; + phone?: string; +} + +interface SupplierSearchModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelectSupplier: (supplier: { name: string; code?: string }) => void; +} + +// ============================================================================= +// API 응답 변환 +// ============================================================================= + +interface ApiClientResponse { + id?: number | string; + name?: string; + client_code?: string; + client_type?: string; + contact_person?: string; + phone?: string; +} + +function transformClientFromApi(apiClient: ApiClientResponse): SupplierItem { + return { + id: String(apiClient.id || ''), + name: apiClient.name || '', + clientCode: apiClient.client_code || '', + clientType: apiClient.client_type || '', + contactPerson: apiClient.contact_person || '', + phone: apiClient.phone || '', + }; +} + +/** + * 매입 가능 거래처 조회 (클라이언트 프록시 경유) + * client_type: PURCHASE 또는 BOTH + */ +async function fetchPurchaseClients(search?: string): Promise { + const params = new URLSearchParams(); + if (search) params.set('q', search); + params.set('size', '50'); + // 매입 가능 거래처만 (PURCHASE, BOTH) + params.set('client_type', 'PURCHASE,BOTH'); + + const url = `/api/proxy/clients?${params.toString()}`; + + 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(); + + let rawItems: ApiClientResponse[] = []; + + if (result.success && result.data) { + if (result.data.data && Array.isArray(result.data.data)) { + rawItems = result.data.data; + } else if (Array.isArray(result.data)) { + rawItems = result.data; + } + } else if (Array.isArray(result)) { + rawItems = result; + } + + return rawItems.map(transformClientFromApi); +} + +// ============================================================================= +// 유효성 검사 +// ============================================================================= + +/** + * 검색어 유효성: 한글 완성형 1자 이상 또는 영문 2자 이상 + */ +function isValidSearchQuery(query: string): boolean { + if (!query || !query.trim()) return false; + const trimmed = query.trim(); + if (/[가-힣]/.test(trimmed)) return true; + const englishChars = trimmed.replace(/[^a-zA-Z]/g, ''); + if (englishChars.length >= 2) return true; + const alphanumeric = trimmed.replace(/[^a-zA-Z0-9]/g, ''); + if (alphanumeric.length >= 2) return true; + return false; +} + +// ============================================================================= +// 컴포넌트 +// ============================================================================= + +export function SupplierSearchModal({ + open, + onOpenChange, + onSelectSupplier, +}: SupplierSearchModalProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [suppliers, setSuppliers] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // 거래처 목록 조회 + const loadSuppliers = useCallback(async (search?: string) => { + setIsLoading(true); + setError(null); + try { + const data = await fetchPurchaseClients(search); + setSuppliers(data); + } catch (err) { + console.error('[SupplierSearchModal] 거래처 조회 오류:', err); + setError('거래처 목록을 불러오는데 실패했습니다.'); + setSuppliers([]); + } finally { + setIsLoading(false); + } + }, []); + + // 모달 열릴 때 초기화 + useEffect(() => { + if (open) { + setSuppliers([]); + setError(null); + } + }, [open]); + + // 검색어 변경 시 디바운스 검색 + useEffect(() => { + if (!open) return; + + if (!isValidSearchQuery(searchQuery)) { + setSuppliers([]); + return; + } + + const timer = setTimeout(() => { + loadSuppliers(searchQuery); + }, 300); + + return () => clearTimeout(timer); + }, [searchQuery, open, loadSuppliers]); + + const handleSelect = (supplier: SupplierItem) => { + onSelectSupplier({ name: supplier.name, code: supplier.clientCode }); + onOpenChange(false); + setSearchQuery(''); + }; + + const handleClose = () => { + onOpenChange(false); + setSearchQuery(''); + }; + + return ( + + + + 발주처 검색 + + + {/* 검색 입력 */} +
+ + setSearchQuery(e.target.value)} + className="pl-10 pr-10" + /> + {searchQuery && ( + + )} +
+ + {/* 거래처 목록 */} +
+ {isLoading ? ( +
+ + 거래처 검색 중... +
+ ) : error ? ( +
+ {error} +
+ ) : suppliers.length === 0 ? ( +
+ {!searchQuery + ? '한글 1자(완성형) 또는 영문 2자 이상 입력하세요' + : !isValidSearchQuery(searchQuery) + ? '한글 1자(완성형) 또는 영문 2자 이상 입력하세요' + : '검색 결과가 없습니다'} +
+ ) : ( +
+ {suppliers.map((supplier, index) => ( +
handleSelect(supplier)} + className="p-3 hover:bg-blue-50 cursor-pointer transition-colors" + > +
+ {supplier.name} + {supplier.clientCode && ( + + {supplier.clientCode} + + )} +
+ {supplier.contactPerson && ( +

담당: {supplier.contactPerson}

+ )} +
+ ))} +
+ )} +
+ + {/* 거래처 개수 표시 */} + {!isLoading && !error && ( +
+ 총 {suppliers.length}개 거래처 +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/quotes/QuoteFooterBar.tsx b/src/components/quotes/QuoteFooterBar.tsx index abf1d54e..81b15b64 100644 --- a/src/components/quotes/QuoteFooterBar.tsx +++ b/src/components/quotes/QuoteFooterBar.tsx @@ -140,10 +140,11 @@ export function QuoteFooterBar({ )} - {/* 할인하기 */} + {/* 할인하기 - view 모드에서는 비활성 */} {onDiscount && (