From f72142f6cc46c7be5bf90803d365c2ecd144c53d Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Mon, 1 Sep 2025 10:22:55 +0000
Subject: (김준회) MDG 자재마스터 정보 조회 기능 및 메뉴 추가, 회원가입시
 공급품목 선택 기능 추가
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 components/common/material/material-selector.tsx | 320 +++++++++++++++++++++++
 1 file changed, 320 insertions(+)
 create mode 100644 components/common/material/material-selector.tsx

(limited to 'components/common/material/material-selector.tsx')

diff --git a/components/common/material/material-selector.tsx b/components/common/material/material-selector.tsx
new file mode 100644
index 00000000..aa68d2b5
--- /dev/null
+++ b/components/common/material/material-selector.tsx
@@ -0,0 +1,320 @@
+"use client";
+
+import React, { useState, useCallback } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+  Command,
+  CommandList,
+  CommandEmpty,
+  CommandGroup,
+  CommandItem,
+} from "@/components/ui/command";
+import { Check, ChevronsUpDown, X, Search, ChevronLeft, ChevronRight } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { useDebounce } from "@/hooks/use-debounce";
+import { searchMaterialsForSelector, MaterialSearchItem } from "@/lib/material/material-group-service";
+
+interface MaterialSelectorProps {
+  selectedMaterials?: MaterialSearchItem[];
+  onMaterialsChange?: (materials: MaterialSearchItem[]) => void;
+  singleSelect?: boolean;
+  placeholder?: string;
+  noValuePlaceHolder?: string;
+  disabled?: boolean;
+  maxSelections?: number;
+  className?: string;
+  closeOnSelect?: boolean;
+}
+
+export function MaterialSelector({
+  selectedMaterials = [],
+  onMaterialsChange,
+  singleSelect = false,
+  placeholder = "자재를 검색하세요...",
+  noValuePlaceHolder = "자재를 검색해주세요",
+  disabled = false,
+  maxSelections,
+  className,
+  closeOnSelect = true
+}: MaterialSelectorProps) {
+  
+  const [open, setOpen] = useState(false);
+  const [searchQuery, setSearchQuery] = useState("");
+  const [searchResults, setSearchResults] = useState<MaterialSearchItem[]>([]);
+  const [isSearching, setIsSearching] = useState(false);
+  const [searchError, setSearchError] = useState<string | null>(null);
+  const [currentPage, setCurrentPage] = useState(1);
+  const [pagination, setPagination] = useState({
+    page: 1,
+    perPage: 10,
+    total: 0,
+    pageCount: 0,
+    hasNextPage: false,
+    hasPrevPage: false,
+  });
+
+  // Debounce 적용된 검색어
+  const debouncedSearchQuery = useDebounce(searchQuery, 300);
+
+  // 검색 실행 - useCallback으로 메모이제이션
+  const performSearch = useCallback(async (query: string, page: number = 1) => {
+    setIsSearching(true);
+    setSearchError(null);
+    
+    try {
+      const result = await searchMaterialsForSelector(query, page, 10);
+      
+      if (result.success) {
+        setSearchResults(result.data);
+        setPagination(result.pagination);
+        setCurrentPage(page);
+      } else {
+        setSearchResults([]);
+        setSearchError("검색 중 오류가 발생했습니다.");
+        setPagination({
+          page: 1,
+          perPage: 10,
+          total: 0,
+          pageCount: 0,
+          hasNextPage: false,
+          hasPrevPage: false,
+        });
+      }
+    } catch (err) {
+      console.error("자재 검색 실패:", err);
+      setSearchResults([]);
+      setSearchError("검색 중 오류가 발생했습니다.");
+      setPagination({
+        page: 1,
+        perPage: 10,
+        total: 0,
+        pageCount: 0,
+        hasNextPage: false,
+        hasPrevPage: false,
+      });
+    } finally {
+      setIsSearching(false);
+    }
+  }, []);
+
+  // Debounced 검색어 변경 시 검색 실행 (검색어가 있을 때만)
+  React.useEffect(() => {
+    if (debouncedSearchQuery.trim()) {
+      setCurrentPage(1);
+      performSearch(debouncedSearchQuery, 1);
+    } else {
+      // 검색어가 없으면 결과 초기화
+      setSearchResults([]);
+      setPagination({
+        page: 1,
+        perPage: 10,
+        total: 0,
+        pageCount: 0,
+        hasNextPage: false,
+        hasPrevPage: false,
+      });
+    }
+  }, [debouncedSearchQuery, performSearch]);
+
+  // 페이지 변경 처리 - useCallback으로 메모이제이션
+  const handlePageChange = useCallback((newPage: number) => {
+    if (newPage >= 1 && newPage <= pagination.pageCount) {
+      performSearch(debouncedSearchQuery, newPage);
+    }
+  }, [pagination.pageCount, performSearch, debouncedSearchQuery]);
+
+  // 자재 선택 처리 - useCallback으로 메모이제이션
+  const handleMaterialSelect = useCallback((material: MaterialSearchItem) => {
+    if (disabled) return;
+
+    let newSelectedMaterials: MaterialSearchItem[];
+
+    if (singleSelect) {
+      newSelectedMaterials = [material];
+    } else {
+      const isAlreadySelected = selectedMaterials.some(
+        (selected) => selected.materialGroupCode === material.materialGroupCode && 
+                      selected.materialName === material.materialName
+      );
+
+      if (isAlreadySelected) {
+        newSelectedMaterials = selectedMaterials.filter(
+          (selected) => !(selected.materialGroupCode === material.materialGroupCode && 
+                         selected.materialName === material.materialName)
+        );
+      } else {
+        if (maxSelections && selectedMaterials.length >= maxSelections) {
+          return; // 최대 선택 수 초과 시 추가하지 않음
+        }
+        newSelectedMaterials = [...selectedMaterials, material];
+      }
+    }
+
+    onMaterialsChange?.(newSelectedMaterials);
+
+    if (closeOnSelect && singleSelect) {
+      setOpen(false);
+    }
+  }, [disabled, singleSelect, selectedMaterials, maxSelections, onMaterialsChange, closeOnSelect]);
+
+  // 개별 자재 제거
+  const handleRemoveMaterial = useCallback((materialToRemove: MaterialSearchItem) => {
+    if (disabled) return;
+    
+    const newSelectedMaterials = selectedMaterials.filter(
+      (material) => !(material.materialGroupCode === materialToRemove.materialGroupCode && 
+                     material.materialName === materialToRemove.materialName)
+    );
+    onMaterialsChange?.(newSelectedMaterials);
+  }, [disabled, selectedMaterials, onMaterialsChange]);
+
+  // 선택된 자재가 있는지 확인
+  const isMaterialSelected = useCallback((material: MaterialSearchItem) => {
+    return selectedMaterials.some(
+      (selected) => selected.materialGroupCode === material.materialGroupCode && 
+                    selected.materialName === material.materialName
+    );
+  }, [selectedMaterials]);
+
+  return (
+    <div className={cn("w-full", className)}>
+      <Popover open={open} onOpenChange={setOpen}>
+        <PopoverTrigger asChild>
+          <Button
+            variant="outline"
+            role="combobox"
+            aria-expanded={open}
+            className="w-full justify-between min-h-[2.5rem] h-auto"
+            disabled={disabled}
+          >
+            <div className="flex flex-wrap gap-1 flex-1 text-left">
+              {selectedMaterials.length === 0 ? (
+                <span className="text-muted-foreground">{noValuePlaceHolder}</span>
+              ) : (
+                selectedMaterials.map((material) => (
+                  <Badge
+                    key={`${material.materialGroupCode}-${material.materialName}`}
+                    variant="secondary"
+                    className="gap-1"
+                  >
+                    <span className="max-w-[200px] truncate">
+                      {material.displayText}
+                    </span>
+                    {!disabled && (
+                      <X
+                        className="h-3 w-3 cursor-pointer hover:text-red-500"
+                        onClick={(e) => {
+                          e.stopPropagation();
+                          handleRemoveMaterial(material);
+                        }}
+                      />
+                    )}
+                  </Badge>
+                ))
+              )}
+            </div>
+            <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+          </Button>
+        </PopoverTrigger>
+        
+        <PopoverContent className="w-full p-0" align="start">
+          <Command>
+            <div className="flex items-center border-b px-3">
+              <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
+              <Input
+                placeholder={placeholder}
+                value={searchQuery}
+                onChange={(e) => setSearchQuery(e.target.value)}
+                className="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none border-0 focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50"
+              />
+            </div>
+
+            <CommandList>
+              <ScrollArea className="h-64">
+                {!searchQuery.trim() ? (
+                  <div className="p-4 text-center text-sm text-muted-foreground">
+                    자재를 검색하려면 검색어를 입력해주세요.
+                  </div>
+                ) : isSearching ? (
+                  <div className="p-4 text-center text-sm text-muted-foreground">
+                    검색 중...
+                  </div>
+                ) : searchError ? (
+                  <div className="p-4 text-center text-sm text-red-500">
+                    {searchError}
+                  </div>
+                ) : searchResults.length === 0 ? (
+                  <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+                ) : (
+                  <CommandGroup>
+                    {searchResults.map((material) => (
+                      <CommandItem
+                        key={`${material.materialGroupCode}-${material.materialName}`}
+                        onSelect={() => handleMaterialSelect(material)}
+                        className="cursor-pointer"
+                      >
+                        <Check
+                          className={cn(
+                            "mr-2 h-4 w-4",
+                            isMaterialSelected(material) ? "opacity-100" : "opacity-0"
+                          )}
+                        />
+                        <div className="flex-1">
+                          <div className="font-medium">{material.materialName}</div>
+                          <div className="text-xs text-muted-foreground">
+                            코드: {material.materialGroupCode}
+                          </div>
+                        </div>
+                      </CommandItem>
+                    ))}
+                  </CommandGroup>
+                )}
+              </ScrollArea>
+
+              {/* 페이지네이션 */}
+              {searchResults.length > 0 && pagination.pageCount > 1 && (
+                <div className="flex items-center justify-between border-t px-3 py-2">
+                  <div className="text-xs text-muted-foreground">
+                    총 {pagination.total}개 중 {((pagination.page - 1) * pagination.perPage) + 1}-
+                    {Math.min(pagination.page * pagination.perPage, pagination.total)}개 표시
+                  </div>
+                  <div className="flex items-center gap-2">
+                    <Button
+                      variant="ghost"
+                      size="sm"
+                      onClick={() => handlePageChange(currentPage - 1)}
+                      disabled={!pagination.hasPrevPage}
+                      className="h-6 w-6 p-0"
+                    >
+                      <ChevronLeft className="h-3 w-3" />
+                    </Button>
+                    <span className="text-xs">
+                      {pagination.page} / {pagination.pageCount}
+                    </span>
+                    <Button
+                      variant="ghost"
+                      size="sm"
+                      onClick={() => handlePageChange(currentPage + 1)}
+                      disabled={!pagination.hasNextPage}
+                      className="h-6 w-6 p-0"
+                    >
+                      <ChevronRight className="h-3 w-3" />
+                    </Button>
+                  </div>
+                </div>
+              )}
+            </CommandList>
+          </Command>
+        </PopoverContent>
+      </Popover>
+    </div>
+  );
+}
-- 
cgit v1.2.3

