summaryrefslogtreecommitdiff
path: root/components/common/selectors
diff options
context:
space:
mode:
Diffstat (limited to 'components/common/selectors')
-rw-r--r--components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx168
-rw-r--r--components/common/selectors/procurement-item/procurement-item-selector.tsx176
-rw-r--r--components/common/selectors/procurement-item/procurement-item-service.ts55
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;
+ }
+}