From f63cf682d6846210a04ce4a3eb8ebe9afd6d6dba Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Tue, 14 Oct 2025 20:27:47 +0900
Subject: (김준회) 자재코드 선택기 구현 및 일반견적 생성시 반영
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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

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

diff --git a/components/common/selectors/material/material-selector-dialog-single.tsx b/components/common/selectors/material/material-selector-dialog-single.tsx
new file mode 100644
index 00000000..0a3b0920
--- /dev/null
+++ b/components/common/selectors/material/material-selector-dialog-single.tsx
@@ -0,0 +1,429 @@
+"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 { Checkbox } from "@/components/ui/checkbox";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "@/components/ui/table";
+import { Search, X, ChevronLeft, ChevronRight } from "lucide-react";
+import { useDebounce } from "@/hooks/use-debounce";
+import { searchMaterialsForDialog, MaterialSearchItem } from "./material-service";
+
+/**
+ * 자재 단일 선택 Dialog 컴포넌트
+ *
+ * @description
+ * - TanStack Table을 사용한 효율적인 자재 선택 Dialog
+ * - 페이지네이션으로 데이터를 조회하고 클라이언트 사이드에서 검색 필터링
+ * - 버튼 클릭 시 Dialog가 열리고, 자재를 선택하면 Dialog가 닫히며 결과를 반환
+ *
+ * @MaterialSearchItem_Structure
+ * 상태에서 관리되는 자재 객체의 형태:
+ * ```typescript
+ * interface MaterialSearchItem {
+ *   materialCode: string;        // 자재코드 (MATNR)
+ *   materialName: string;        // 자재명 (ZZNAME)
+ *   displayText: string;         // 표시용 텍스트 (code + " - " + name)
+ * }
+ * ```
+ *
+ * @state
+ * - open: Dialog 열림/닫힘 상태
+ * - selectedMaterial: 현재 선택된 자재 (단일)
+ * - tempSelectedMaterial: Dialog 내에서 임시로 선택된 자재 (확인 버튼 클릭 전까지)
+ * - searchQuery: 검색어
+ * - currentPage: 현재 페이지
+ * - searchResults: 검색 결과
+ * - isSearching: 검색 중 상태
+ * - error: 에러 상태
+ *
+ * @callback
+ * - onMaterialSelect: 자재 선택 완료 시 호출되는 콜백
+ *   - 매개변수: MaterialSearchItem | null
+ *   - 선택된 자재 정보 또는 null (선택 해제 시)
+ *
+ * @usage
+ * ```tsx
+ * <MaterialSelectorDialogSingle
+ *   triggerLabel="자재 선택"
+ *   selectedMaterial={selectedMaterial}
+ *   onMaterialSelect={(material) => {
+ *     setSelectedMaterial(material);
+ *     console.log('선택된 자재:', material);
+ *   }}
+ *   placeholder="자재코드를 검색하세요..."
+ * />
+ * ```
+ */
+
+interface MaterialSelectorDialogSingleProps {
+  /** Dialog를 여는 트리거 버튼 텍스트 */
+  triggerLabel?: string;
+  /** 현재 선택된 자재 */
+  selectedMaterial?: MaterialSearchItem | null;
+  /** 자재 선택 완료 시 호출되는 콜백 */
+  onMaterialSelect?: (material: MaterialSearchItem | null) => void;
+  /** 검색 입력창 placeholder */
+  placeholder?: string;
+  /** Dialog 제목 */
+  title?: string;
+  /** Dialog 설명 */
+  description?: string;
+  /** 트리거 버튼 비활성화 여부 */
+  disabled?: boolean;
+  /** 트리거 버튼 variant */
+  triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
+  /** 제외할 자재코드들 */
+  excludeMaterialCodes?: Set<string>;
+}
+
+export function MaterialSelectorDialogSingle({
+  triggerLabel = "자재 선택",
+  selectedMaterial = null,
+  onMaterialSelect,
+  placeholder = "자재코드를 검색하세요...",
+  title = "자재 선택",
+  description = "원하는 자재를 검색하고 선택해주세요.",
+  disabled = false,
+  triggerVariant = "outline",
+  excludeMaterialCodes,
+}: MaterialSelectorDialogSingleProps) {
+  // Dialog 열림/닫힘 상태
+  const [open, setOpen] = useState(false);
+
+  // Dialog 내에서 임시로 선택된 자재 (확인 버튼 클릭 전까지)
+  const [tempSelectedMaterial, setTempSelectedMaterial] = useState<MaterialSearchItem | null>(null);
+
+  // 검색어
+  const [searchQuery, setSearchQuery] = useState("");
+
+  // 페이지네이션 상태
+  const [currentPage, setCurrentPage] = useState(1);
+  const [searchResults, setSearchResults] = useState<MaterialSearchItem[]>([]);
+  const [pagination, setPagination] = useState({
+    page: 1,
+    perPage: 10,
+    total: 0,
+    pageCount: 0,
+    hasNextPage: false,
+    hasPrevPage: false,
+  });
+
+  // 로딩 상태
+  const [isSearching, setIsSearching] = useState(false);
+
+  // 에러 상태
+  const [error, setError] = useState<string | null>(null);
+
+  // Debounce 적용된 검색어
+  const debouncedSearchQuery = useDebounce(searchQuery, 300);
+
+  // 검색 실행 함수
+  const performSearch = useCallback(async (query: string, page: number = 1) => {
+    setIsSearching(true);
+    setError(null);
+
+    try {
+      const result = await searchMaterialsForDialog(query, page, 10);
+
+      if (result.success) {
+        setSearchResults(result.data);
+        setPagination(result.pagination);
+        setCurrentPage(page);
+      } else {
+        setSearchResults([]);
+        setError(result.error || "검색 중 오류가 발생했습니다.");
+        setPagination({
+          page: 1,
+          perPage: 10,
+          total: 0,
+          pageCount: 0,
+          hasNextPage: false,
+          hasPrevPage: false,
+        });
+      }
+    } catch (err) {
+      console.error("자재 검색 실패:", err);
+      setSearchResults([]);
+      setError("검색 중 오류가 발생했습니다.");
+      setPagination({
+        page: 1,
+        perPage: 10,
+        total: 0,
+        pageCount: 0,
+        hasNextPage: false,
+        hasPrevPage: false,
+      });
+    } finally {
+      setIsSearching(false);
+    }
+  }, []);
+
+  // Dialog 열림 시 초기화 및 첫 검색
+  const handleOpenChange = useCallback((newOpen: boolean) => {
+    setOpen(newOpen);
+    if (newOpen) {
+      setTempSelectedMaterial(selectedMaterial || null);
+      setSearchQuery("");
+      setCurrentPage(1);
+      // 초기 데이터 로드
+      performSearch("", 1);
+    }
+  }, [selectedMaterial, performSearch]);
+
+  // 검색어 변경 시 검색 실행
+  React.useEffect(() => {
+    if (open) {
+      setCurrentPage(1);
+      performSearch(debouncedSearchQuery, 1);
+    }
+  }, [debouncedSearchQuery, performSearch, open]);
+
+  // 페이지 변경 처리
+  const handlePageChange = useCallback((newPage: number) => {
+    if (newPage >= 1 && newPage <= pagination.pageCount) {
+      setCurrentPage(newPage);
+      performSearch(debouncedSearchQuery, newPage);
+    }
+  }, [pagination.pageCount, performSearch, debouncedSearchQuery]);
+
+  // 자재 선택 처리
+  const handleMaterialSelect = useCallback((material: MaterialSearchItem) => {
+    setTempSelectedMaterial(material);
+  }, []);
+
+  // 선택 해제
+  const handleClear = useCallback(() => {
+    setTempSelectedMaterial(null);
+  }, []);
+
+  // 확인 버튼 클릭 시 선택 완료
+  const handleConfirm = useCallback(() => {
+    onMaterialSelect?.(tempSelectedMaterial);
+    setOpen(false);
+  }, [tempSelectedMaterial, onMaterialSelect]);
+
+  // 취소 버튼 클릭 시
+  const handleCancel = useCallback(() => {
+    setTempSelectedMaterial(selectedMaterial || null);
+    setOpen(false);
+  }, [selectedMaterial]);
+
+  return (
+    <Dialog open={open} onOpenChange={handleOpenChange}>
+      <DialogTrigger asChild>
+        <Button variant={triggerVariant} disabled={disabled}>
+          {selectedMaterial ? (
+            <span className="truncate">
+              {selectedMaterial.displayText}
+            </span>
+          ) : (
+            triggerLabel
+          )}
+        </Button>
+      </DialogTrigger>
+
+      <DialogContent className="max-w-4xl max-h-[80vh]">
+        <DialogHeader>
+          <DialogTitle>{title}</DialogTitle>
+          <DialogDescription>{description}</DialogDescription>
+        </DialogHeader>
+
+        <div className="space-y-4">
+          {/* 검색 입력창 */}
+          <div className="relative">
+            <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
+            <Input
+              placeholder={placeholder}
+              value={searchQuery}
+              onChange={(e) => setSearchQuery(e.target.value)}
+              className="pl-10"
+            />
+          </div>
+
+          {/* 선택된 자재 표시 */}
+          {tempSelectedMaterial && (
+            <div className="flex items-center gap-2 p-3 bg-blue-50 rounded-lg border">
+              <span className="text-sm font-medium text-blue-900">선택됨:</span>
+              <Badge variant="secondary" className="gap-1">
+                <span>{tempSelectedMaterial.displayText}</span>
+                <button
+                  type="button"
+                  onClick={handleClear}
+                  className="ml-1 h-3 w-3 rounded-sm hover:bg-red-100 flex items-center justify-center"
+                >
+                  <X className="h-3 w-3 hover:text-red-500" />
+                </button>
+              </Badge>
+            </div>
+          )}
+
+          {/* 테이블 */}
+          <div className="border rounded-lg max-h-96 overflow-y-auto">
+            {isSearching ? (
+              <div className="p-8 text-center text-muted-foreground">
+                검색 중...
+              </div>
+            ) : error ? (
+              <div className="p-8 text-center text-red-500">
+                {error}
+              </div>
+            ) : (
+              <Table>
+                <TableHeader>
+                  <TableRow>
+                    <TableHead className="w-12">선택</TableHead>
+                    <TableHead>자재코드</TableHead>
+                    <TableHead>자재명</TableHead>
+                  </TableRow>
+                </TableHeader>
+                <TableBody>
+                  {searchResults.length === 0 ? (
+                    <TableRow>
+                      <TableCell colSpan={3} className="text-center py-8 text-muted-foreground">
+                        {debouncedSearchQuery.trim() ? "검색 결과가 없습니다." : "자재가 없습니다."}
+                      </TableCell>
+                    </TableRow>
+                  ) : (
+                    searchResults.map((material) => {
+                      const isExcluded = excludeMaterialCodes?.has(material.materialCode);
+                      const isSelected = tempSelectedMaterial?.materialCode === material.materialCode;
+
+                      return (
+                        <TableRow
+                          key={material.materialCode}
+                          className={`cursor-pointer hover:bg-muted/50 ${
+                            isExcluded ? 'opacity-50 cursor-not-allowed' : ''
+                          } ${isSelected ? 'bg-blue-50' : ''}`}
+                          onClick={() => {
+                            if (!isExcluded) {
+                              handleMaterialSelect(material);
+                            }
+                          }}
+                        >
+                          <TableCell>
+                            <Checkbox
+                              checked={isSelected}
+                              disabled={isExcluded}
+                              onCheckedChange={() => {
+                                if (!isExcluded) {
+                                  handleMaterialSelect(material);
+                                }
+                              }}
+                            />
+                          </TableCell>
+                          <TableCell className="font-mono text-sm">
+                            {material.materialCode}
+                            {isExcluded && (
+                              <span className="ml-2 text-xs bg-red-100 text-red-600 px-2 py-1 rounded">
+                                제외됨
+                              </span>
+                            )}
+                          </TableCell>
+                          <TableCell>{material.materialName}</TableCell>
+                        </TableRow>
+                      );
+                    })
+                  )}
+                </TableBody>
+              </Table>
+            )}
+          </div>
+
+          {/* 페이지네이션 */}
+          {searchResults.length > 0 && pagination.pageCount > 1 && (
+            <div className="flex items-center justify-between">
+              <div className="text-sm 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="outline"
+                  size="sm"
+                  onClick={() => handlePageChange(currentPage - 1)}
+                  disabled={!pagination.hasPrevPage || isSearching}
+                >
+                  <ChevronLeft className="h-4 w-4" />
+                  이전
+                </Button>
+                <span className="text-sm">
+                  {pagination.page} / {pagination.pageCount}
+                </span>
+                <Button
+                  variant="outline"
+                  size="sm"
+                  onClick={() => handlePageChange(currentPage + 1)}
+                  disabled={!pagination.hasNextPage || isSearching}
+                >
+                  다음
+                  <ChevronRight className="h-4 w-4" />
+                </Button>
+              </div>
+            </div>
+          )}
+        </div>
+
+        <DialogFooter className="gap-2">
+          <Button variant="outline" onClick={handleCancel}>
+            취소
+          </Button>
+          {tempSelectedMaterial && (
+            <Button variant="ghost" onClick={handleClear}>
+              선택 해제
+            </Button>
+          )}
+          <Button onClick={handleConfirm}>
+            확인
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+/**
+ * 사용 예시:
+ *
+ * ```tsx
+ * const [selectedMaterial, setSelectedMaterial] = useState<MaterialSearchItem | null>(null);
+ *
+ * return (
+ *   <MaterialSelectorDialogSingle
+ *     triggerLabel="자재 선택"
+ *     selectedMaterial={selectedMaterial}
+ *     onMaterialSelect={(material) => {
+ *       setSelectedMaterial(material);
+ *       if (material) {
+ *         console.log('선택된 자재:', {
+ *           code: material.materialCode,
+ *           name: material.materialName,
+ *           displayText: material.displayText
+ *         });
+ *       } else {
+ *         console.log('자재 선택이 해제되었습니다.');
+ *       }
+ *     }}
+ *     title="자재 선택"
+ *     description="필요한 자재를 검색하고 선택해주세요."
+ *   />
+ * );
+ * ```
+ */
-- 
cgit v1.2.3

