diff options
Diffstat (limited to 'components/common/selectors')
3 files changed, 399 insertions, 0 deletions
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 + * <ProcurementItemSelectorDialogSingle + * triggerLabel="품목 선택" + * selectedProcurementItem={selectedProcurementItem} + * onProcurementItemSelect={(item) => { + * 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<ProcurementSearchItem | null>(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 ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button variant={triggerVariant} size={triggerSize}> + {selectedProcurementItem ? ( + <span className="truncate"> + {`${selectedProcurementItem.itemCode} - ${selectedProcurementItem.itemName}`} + </span> + ) : ( + <span className="text-muted-foreground">{triggerLabel}</span> + )} + </Button> + </DialogTrigger> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col"> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + <DialogDescription>{description}</DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-hidden"> + <ProcurementItemSelector + selectedProcurementItem={tempSelectedProcurementItem} + onProcurementItemSelect={handleProcurementItemSelect} + onClear={handleClear} + /> + </div> + + {showConfirmButtons && ( + <DialogFooter> + <Button variant="outline" onClick={handleCancel}> + 취소 + </Button> + <Button onClick={handleConfirm}> + 확인 + </Button> + </DialogFooter> + )} + </DialogContent> + </Dialog> + ); +} 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<ProcurementSearchItem[]>([]); + 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 ( + <div className={cn("flex items-center space-x-2", className)}> + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className="w-full justify-between" + disabled={disabled} + > + {selectedProcurementItem ? ( + <span className="truncate"> + {selectedProcurementItem.displayText} + </span> + ) : ( + <span className="text-muted-foreground">{placeholder}</span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <div className="p-2"> + <div className="relative"> + <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="품목코드 또는 품목명으로 검색..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + autoFocus + /> + </div> + </div> + <Command> + <CommandList> + <CommandEmpty> + {isLoading ? ( + <div className="py-6 text-center text-sm text-muted-foreground"> + 검색 중... + </div> + ) : searchQuery.length < 1 ? ( + <div className="py-6 text-center text-sm text-muted-foreground"> + 품목코드 또는 품목명을 입력하세요 + </div> + ) : ( + <div className="py-6 text-center text-sm text-muted-foreground"> + 검색 결과가 없습니다 + </div> + )} + </CommandEmpty> + <CommandGroup> + {searchResults.map((item) => ( + <CommandItem + key={item.itemCode} + value={item.itemCode} + onSelect={() => handleSelect(item)} + className="cursor-pointer" + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedProcurementItem?.itemCode === item.itemCode + ? "opacity-100" + : "opacity-0" + )} + /> + <div className="flex flex-col"> + <span className="font-medium">{item.itemCode}</span> + <span className="text-sm text-muted-foreground">{item.itemName}</span> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + + {/* 선택 해제 버튼 */} + {selectedProcurementItem && ( + <Button + variant="ghost" + size="sm" + onClick={handleClear} + disabled={disabled} + className="h-8 w-8 p-0" + > + <X className="h-4 w-4" /> + </Button> + )} + </div> + ); +} 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<ProcurementSearchItem[]> { + 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<ProcurementSearchItem | null> { + 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; + } +} |
