From 8642ee064ddf96f1db2b948b4cc8bbbd6cfee820 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 12 Nov 2025 10:42:36 +0000 Subject: (최겸) 구매 일반계약, 입찰 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../procurement-item-selector-dialog-single.tsx | 168 ++++++++++++++++++++ .../procurement-item/procurement-item-selector.tsx | 176 +++++++++++++++++++++ .../procurement-item/procurement-item-service.ts | 55 +++++++ 3 files changed, 399 insertions(+) create mode 100644 components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx create mode 100644 components/common/selectors/procurement-item/procurement-item-selector.tsx create mode 100644 components/common/selectors/procurement-item/procurement-item-service.ts (limited to 'components/common') diff --git a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx new file mode 100644 index 00000000..dab65780 --- /dev/null +++ b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx @@ -0,0 +1,168 @@ +"use client"; + +import React, { useState, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { ProcurementItemSelector } from "./procurement-item-selector"; +import { ProcurementSearchItem } from "./procurement-item-service"; + +export interface ProcurementItemSelectorDialogSingleProps { + triggerLabel?: string; + triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"; + triggerSize?: "default" | "sm" | "lg" | "icon"; + selectedProcurementItem?: ProcurementSearchItem | null; + onProcurementItemSelect?: (item: ProcurementSearchItem | null) => void; + title?: string; + description?: string; + showConfirmButtons?: boolean; +} + +/** + * 품목 단일 선택 Dialog 컴포넌트 + * + * @description + * - ProcurementItemSelector를 Dialog로 래핑한 단일 선택 컴포넌트 + * - 버튼 클릭 시 Dialog가 열리고, 품목을 선택하면 Dialog가 닫히며 결과를 반환 + * + * @ProcurementSearchItem_Structure + * 상태에서 관리되는 품목 객체의 형태: + * ```typescript + * interface ProcurementSearchItem { + * itemCode: string; // 품목코드 + * itemName: string; // 품목명 + * material?: string; // 재질 + * specification?: string; // 규격 + * unit?: string; // 단위 + * displayText: string; // 표시용 텍스트 (code + " - " + name) + * } + * ``` + * + * @state + * - open: Dialog 열림/닫힘 상태 + * - selectedProcurementItem: 현재 선택된 품목 (단일) + * - tempSelectedProcurementItem: Dialog 내에서 임시로 선택된 품목 (확인 버튼 클릭 전까지) + * + * @callback + * - onProcurementItemSelect: 품목 선택 완료 시 호출되는 콜백 + * - 매개변수: ProcurementSearchItem | null + * - 선택된 품목 정보 또는 null (선택 해제 시) + * + * @usage + * ```tsx + * { + * console.log('선택된 품목:', item); + * setSelectedProcurementItem(item); + * }} + * title="품목 선택" + * description="품목을 검색하고 선택해주세요." + * /> + * ``` + */ +export function ProcurementItemSelectorDialogSingle({ + triggerLabel = "품목 선택", + triggerVariant = "outline", + triggerSize = "default", + selectedProcurementItem = null, + onProcurementItemSelect, + title = "품목 선택", + description = "품목을 검색하고 선택해주세요.", + showConfirmButtons = false, +}: ProcurementItemSelectorDialogSingleProps) { + const [open, setOpen] = useState(false); + const [tempSelectedProcurementItem, setTempSelectedProcurementItem] = + useState(selectedProcurementItem); + + // Dialog가 열릴 때 임시 선택 상태 초기화 + const handleOpenChange = useCallback((newOpen: boolean) => { + setOpen(newOpen); + if (newOpen) { + // Dialog 열 때 현재 선택된 값으로 임시 상태 초기화 + setTempSelectedProcurementItem(selectedProcurementItem); + } + }, [selectedProcurementItem]); + + // 품목 선택 시 임시 상태 업데이트 + const handleProcurementItemSelect = useCallback((item: ProcurementSearchItem | null) => { + setTempSelectedProcurementItem(item); + + // 확인 버튼이 없는 경우 즉시 적용하고 Dialog 닫기 + if (!showConfirmButtons) { + onProcurementItemSelect?.(item); + setOpen(false); + } + }, [onProcurementItemSelect, showConfirmButtons]); + + // 확인 버튼 클릭 시 선택 적용 + const handleConfirm = useCallback(() => { + onProcurementItemSelect?.(tempSelectedProcurementItem); + setOpen(false); + }, [onProcurementItemSelect, tempSelectedProcurementItem]); + + // 취소 버튼 클릭 시 Dialog 닫기 (변경사항 적용 안 함) + const handleCancel = useCallback(() => { + setOpen(false); + }, []); + + // 선택 해제 + const handleClear = useCallback(() => { + const newSelection = null; + setTempSelectedProcurementItem(newSelection); + + if (!showConfirmButtons) { + onProcurementItemSelect?.(newSelection); + setOpen(false); + } + }, [onProcurementItemSelect, showConfirmButtons]); + + return ( + + + + + + + {title} + {description} + + +
+ +
+ + {showConfirmButtons && ( + + + + + )} +
+
+ ); +} diff --git a/components/common/selectors/procurement-item/procurement-item-selector.tsx b/components/common/selectors/procurement-item/procurement-item-selector.tsx new file mode 100644 index 00000000..5650959c --- /dev/null +++ b/components/common/selectors/procurement-item/procurement-item-selector.tsx @@ -0,0 +1,176 @@ +"use client"; + +import React, { useState, useCallback, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Command, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Check, ChevronsUpDown, X, Search } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useDebounce } from "@/hooks/use-debounce"; +import { searchProcurementItemsForSelector, ProcurementSearchItem, getProcurementItemByCode } from "./procurement-item-service"; + +interface ProcurementItemSelectorProps { + selectedProcurementItem?: ProcurementSearchItem | null; + onProcurementItemSelect?: (item: ProcurementSearchItem | null) => void; + onClear?: () => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export function ProcurementItemSelector({ + selectedProcurementItem, + onProcurementItemSelect, + onClear, + placeholder = "품목을 검색하세요...", + disabled = false, + className, +}: ProcurementItemSelectorProps) { + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const debouncedSearchQuery = useDebounce(searchQuery, 300); + + // 검색 쿼리가 변경될 때마다 검색 실행 + useEffect(() => { + const performSearch = async () => { + if (debouncedSearchQuery.length < 1) { + setSearchResults([]); + return; + } + + setIsLoading(true); + try { + const results = await searchProcurementItemsForSelector(debouncedSearchQuery); + setSearchResults(results); + } catch (error) { + console.error("품목 검색 실패:", error); + setSearchResults([]); + } finally { + setIsLoading(false); + } + }; + + performSearch(); + }, [debouncedSearchQuery]); + + // 품목 선택 핸들러 + const handleSelect = useCallback((item: ProcurementSearchItem) => { + onProcurementItemSelect?.(item); + setOpen(false); + setSearchQuery(""); + setSearchResults([]); + }, [onProcurementItemSelect]); + + // 선택 해제 핸들러 + const handleClear = useCallback(() => { + onProcurementItemSelect?.(null); + onClear?.(); + setSearchQuery(""); + setSearchResults([]); + }, [onProcurementItemSelect, onClear]); + + return ( +
+ + + + + +
+
+ + setSearchQuery(e.target.value)} + className="pl-8" + autoFocus + /> +
+
+ + + + {isLoading ? ( +
+ 검색 중... +
+ ) : searchQuery.length < 1 ? ( +
+ 품목코드 또는 품목명을 입력하세요 +
+ ) : ( +
+ 검색 결과가 없습니다 +
+ )} +
+ + {searchResults.map((item) => ( + handleSelect(item)} + className="cursor-pointer" + > + +
+ {item.itemCode} + {item.itemName} +
+
+ ))} +
+
+
+
+
+ + {/* 선택 해제 버튼 */} + {selectedProcurementItem && ( + + )} +
+ ); +} diff --git a/components/common/selectors/procurement-item/procurement-item-service.ts b/components/common/selectors/procurement-item/procurement-item-service.ts new file mode 100644 index 00000000..8e7b2c12 --- /dev/null +++ b/components/common/selectors/procurement-item/procurement-item-service.ts @@ -0,0 +1,55 @@ +import { searchProcurementItems } from "@/lib/procurement-items/service"; + +/** + * 품목 검색을 위한 인터페이스 + */ +export interface ProcurementSearchItem { + itemCode: string; // 품목코드 + itemName: string; // 품목명 + material?: string; // 재질 + specification?: string; // 규격 + unit?: string; // 단위 + displayText: string; // 표시용 텍스트 (code + " - " + name) +} + +/** + * 품목 검색 함수 + * procurement-items 서비스를 통해 품목을 검색합니다. + */ +export async function searchProcurementItemsForSelector(query: string): Promise { + try { + const results = await searchProcurementItems(query); + + return results.map(item => ({ + itemCode: item.itemCode, + itemName: item.itemName, + displayText: `${item.itemCode} - ${item.itemName}`, + })); + } catch (error) { + console.error("품목 검색 오류:", error); + return []; + } +} + +/** + * 품목코드로 품목 상세 정보 조회 + */ +export async function getProcurementItemByCode(itemCode: string): Promise { + try { + const results = await searchProcurementItems(itemCode); + + const exactMatch = results.find(item => item.itemCode === itemCode); + if (exactMatch) { + return { + itemCode: exactMatch.itemCode, + itemName: exactMatch.itemName, + displayText: `${exactMatch.itemCode} - ${exactMatch.itemName}`, + }; + } + + return null; + } catch (error) { + console.error("품목 상세 조회 오류:", error); + return null; + } +} -- cgit v1.2.3