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 && (