summaryrefslogtreecommitdiff
path: root/components/common/selectors/material
diff options
context:
space:
mode:
Diffstat (limited to 'components/common/selectors/material')
-rw-r--r--components/common/selectors/material/material-selector-dialog-multi.tsx538
-rw-r--r--components/common/selectors/material/material-selector-dialog-single.tsx429
-rw-r--r--components/common/selectors/material/material-selector.tsx395
-rw-r--r--components/common/selectors/material/material-service.ts155
4 files changed, 1517 insertions, 0 deletions
diff --git a/components/common/selectors/material/material-selector-dialog-multi.tsx b/components/common/selectors/material/material-selector-dialog-multi.tsx
new file mode 100644
index 00000000..20d0a4f7
--- /dev/null
+++ b/components/common/selectors/material/material-selector-dialog-multi.tsx
@@ -0,0 +1,538 @@
+"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가 열리고, 여러 자재를 선택한 후 확인 버튼으로 완료
+ * - 최대 선택 개수 제한 가능
+ *
+ * @MaterialSearchItem_Structure
+ * 상태에서 관리되는 자재 객체의 형태:
+ * ```typescript
+ * interface MaterialSearchItem {
+ * materialCode: string; // 자재코드 (MATNR)
+ * materialName: string; // 자재명 (ZZNAME)
+ * displayText: string; // 표시용 텍스트 (code + " - " + name)
+ * }
+ * ```
+ *
+ * @state
+ * - open: Dialog 열림/닫힘 상태
+ * - selectedMaterials: 현재 선택된 자재들 (배열)
+ * - tempSelectedMaterials: Dialog 내에서 임시로 선택된 자재들 (확인 버튼 클릭 전까지)
+ * - searchQuery: 검색어
+ * - currentPage: 현재 페이지
+ * - searchResults: 검색 결과
+ * - isSearching: 검색 중 상태
+ * - error: 에러 상태
+ *
+ * @callback
+ * - onMaterialsSelect: 자재 선택 완료 시 호출되는 콜백
+ * - 매개변수: MaterialSearchItem[]
+ * - 선택된 자재들의 배열 (빈 배열일 수도 있음)
+ *
+ * @usage
+ * ```tsx
+ * <MaterialSelectorDialogMulti
+ * triggerLabel="자재 선택 (다중)"
+ * selectedMaterials={selectedMaterials}
+ * onMaterialsSelect={(materials) => {
+ * setSelectedMaterials(materials);
+ * console.log('선택된 자재들:', materials);
+ * }}
+ * maxSelections={5}
+ * placeholder="자재코드를 검색하세요..."
+ * />
+ * ```
+ */
+
+interface MaterialSelectorDialogMultiProps {
+ /** Dialog를 여는 트리거 버튼 텍스트 */
+ triggerLabel?: string;
+ /** 현재 선택된 자재들 */
+ selectedMaterials?: MaterialSearchItem[];
+ /** 자재 선택 완료 시 호출되는 콜백 */
+ onMaterialsSelect?: (materials: MaterialSearchItem[]) => void;
+ /** 최대 선택 가능한 자재 개수 */
+ maxSelections?: number;
+ /** 검색 입력창 placeholder */
+ placeholder?: string;
+ /** Dialog 제목 */
+ title?: string;
+ /** Dialog 설명 */
+ description?: string;
+ /** 트리거 버튼 비활성화 여부 */
+ disabled?: boolean;
+ /** 트리거 버튼 variant */
+ triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
+ /** 제외할 자재코드들 */
+ excludeMaterialCodes?: Set<string>;
+ /** 트리거 버튼에서 선택된 자재들을 표시할지 여부 */
+ showSelectedInTrigger?: boolean;
+}
+
+export function MaterialSelectorDialogMulti({
+ triggerLabel = "자재 선택",
+ selectedMaterials = [],
+ onMaterialsSelect,
+ maxSelections,
+ placeholder = "자재코드를 검색하세요...",
+ title = "자재 선택 (다중)",
+ description,
+ disabled = false,
+ triggerVariant = "outline",
+ excludeMaterialCodes,
+ showSelectedInTrigger = true,
+}: MaterialSelectorDialogMultiProps) {
+ // Dialog 열림/닫힘 상태
+ const [open, setOpen] = useState(false);
+
+ // Dialog 내에서 임시로 선택된 자재들 (확인 버튼 클릭 전까지)
+ const [tempSelectedMaterials, setTempSelectedMaterials] = useState<MaterialSearchItem[]>([]);
+
+ // 검색어
+ 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);
+
+ // Dialog 설명 동적 생성
+ const dialogDescription = description ||
+ (maxSelections
+ ? `원하는 자재를 검색하고 선택해주세요. (최대 ${maxSelections}개)`
+ : "원하는 자재를 검색하고 선택해주세요."
+ );
+
+ // 검색 실행 함수
+ 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) {
+ setTempSelectedMaterials([...selectedMaterials]);
+ setSearchQuery("");
+ setCurrentPage(1);
+ // 초기 데이터 로드
+ performSearch("", 1);
+ }
+ }, [selectedMaterials, 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 handleMaterialToggle = useCallback((material: MaterialSearchItem) => {
+ setTempSelectedMaterials(prev => {
+ const isSelected = prev.some(m => m.materialCode === material.materialCode);
+ if (isSelected) {
+ // 선택 해제
+ return prev.filter(m => m.materialCode !== material.materialCode);
+ } else {
+ // 선택 추가 (최대 개수 확인)
+ if (maxSelections && prev.length >= maxSelections) {
+ return prev; // 최대 개수에 도달한 경우 추가하지 않음
+ }
+ return [...prev, material];
+ }
+ });
+ }, [maxSelections]);
+
+ // 전체 선택 해제
+ const handleClearAll = useCallback(() => {
+ setTempSelectedMaterials([]);
+ }, []);
+
+ // 확인 버튼 클릭 시 선택 완료
+ const handleConfirm = useCallback(() => {
+ onMaterialsSelect?.(tempSelectedMaterials);
+ setOpen(false);
+ }, [tempSelectedMaterials, onMaterialsSelect]);
+
+ // 취소 버튼 클릭 시
+ const handleCancel = useCallback(() => {
+ setTempSelectedMaterials([...selectedMaterials]);
+ setOpen(false);
+ }, [selectedMaterials]);
+
+ // 개별 자재 제거 (트리거 버튼에서)
+ const handleRemoveMaterial = useCallback((materialToRemove: MaterialSearchItem, e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const newMaterials = selectedMaterials.filter(
+ (material) => !(material.materialCode === materialToRemove.materialCode &&
+ material.materialName === materialToRemove.materialName)
+ );
+ onMaterialsSelect?.(newMaterials);
+ }, [selectedMaterials, onMaterialsSelect]);
+
+ // 트리거 버튼 렌더링
+ const renderTriggerContent = () => {
+ if (selectedMaterials.length === 0) {
+ return triggerLabel;
+ }
+
+ if (!showSelectedInTrigger) {
+ return `${triggerLabel} (${selectedMaterials.length}개)`;
+ }
+
+ return (
+ <div className="flex flex-wrap gap-1 items-center max-w-full">
+ <span className="shrink-0">{triggerLabel}:</span>
+ {selectedMaterials.slice(0, 2).map((material) => (
+ <Badge
+ key={`${material.materialCode}-${material.materialName}`}
+ variant="secondary"
+ className="gap-1 pr-1 max-w-[120px]"
+ >
+ <span className="truncate text-xs">
+ {material.materialName}
+ </span>
+ {!disabled && (
+ <button
+ type="button"
+ className="ml-1 h-3 w-3 rounded-sm hover:bg-red-100 flex items-center justify-center"
+ onClick={(e) => handleRemoveMaterial(material, e)}
+ >
+ <X className="h-3 w-3 hover:text-red-500" />
+ </button>
+ )}
+ </Badge>
+ ))}
+ {selectedMaterials.length > 2 && (
+ <Badge variant="outline" className="text-xs">
+ +{selectedMaterials.length - 2}개
+ </Badge>
+ )}
+ </div>
+ );
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogTrigger asChild>
+ <Button
+ variant={triggerVariant}
+ disabled={disabled}
+ className="min-h-[2.5rem] h-auto justify-start"
+ >
+ {renderTriggerContent()}
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="max-w-4xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ <DialogDescription>{dialogDescription}</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>
+
+ {/* 선택된 자재들 표시 */}
+ {tempSelectedMaterials.length > 0 && (
+ <div className="p-3 bg-blue-50 rounded-lg border">
+ <div className="flex items-center justify-between mb-2">
+ <span className="text-sm font-medium text-blue-900">
+ 선택됨: {tempSelectedMaterials.length}
+ {maxSelections && `/${maxSelections}`}개
+ </span>
+ <Button variant="ghost" size="sm" onClick={handleClearAll}>
+ 전체 해제
+ </Button>
+ </div>
+ <div className="flex flex-wrap gap-1">
+ {tempSelectedMaterials.map((material) => (
+ <Badge key={material.materialCode} variant="secondary" className="gap-1 pr-1">
+ <span className="text-xs">{material.displayText}</span>
+ <button
+ type="button"
+ onClick={() => handleMaterialToggle(material)}
+ 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>
+ )}
+
+ {/* 테이블 */}
+ <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 = tempSelectedMaterials.some(m => m.materialCode === material.materialCode);
+ const isMaxReached = Boolean(maxSelections && tempSelectedMaterials.length >= maxSelections && !isSelected);
+
+ return (
+ <TableRow
+ key={material.materialCode}
+ className={`cursor-pointer hover:bg-muted/50 ${
+ isExcluded || isMaxReached ? 'opacity-50 cursor-not-allowed' : ''
+ } ${isSelected ? 'bg-blue-50' : ''}`}
+ onClick={() => {
+ if (!isExcluded && !isMaxReached) {
+ handleMaterialToggle(material);
+ }
+ }}
+ >
+ <TableCell>
+ <Checkbox
+ checked={isSelected}
+ disabled={isExcluded || isMaxReached}
+ onCheckedChange={() => {
+ if (!isExcluded && !isMaxReached) {
+ handleMaterialToggle(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>
+ )}
+ {isMaxReached && (
+ <span className="ml-2 text-xs bg-gray-100 text-gray-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>
+ {tempSelectedMaterials.length > 0 && (
+ <Button variant="ghost" onClick={handleClearAll}>
+ 전체 해제
+ </Button>
+ )}
+ <Button onClick={handleConfirm}>
+ 확인 ({tempSelectedMaterials.length}개)
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+/**
+ * 사용 예시:
+ *
+ * ```tsx
+ * const [selectedMaterials, setSelectedMaterials] = useState<MaterialSearchItem[]>([]);
+ *
+ * return (
+ * <MaterialSelectorDialogMulti
+ * triggerLabel="자재 선택"
+ * selectedMaterials={selectedMaterials}
+ * onMaterialsSelect={(materials) => {
+ * setSelectedMaterials(materials);
+ * console.log('선택된 자재들:', materials.map(m => ({
+ * code: m.materialCode,
+ * name: m.materialName,
+ * displayText: m.displayText
+ * })));
+ * }}
+ * maxSelections={5}
+ * title="필수 자재 선택"
+ * description="프로젝트에 필요한 자재들을 선택해주세요."
+ * showSelectedInTrigger={true}
+ * />
+ * );
+ * ```
+ *
+ * @advanced_usage
+ * ```tsx
+ * // 제외할 자재가 있는 경우
+ * const excludedCodes = new Set(['MAT001', 'MAT002']);
+ *
+ * <MaterialSelectorDialogMulti
+ * selectedMaterials={selectedMaterials}
+ * onMaterialsSelect={setSelectedMaterials}
+ * excludeMaterialCodes={excludedCodes}
+ * maxSelections={3}
+ * triggerVariant="default"
+ * showSelectedInTrigger={false}
+ * />
+ * ```
+ */
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="필요한 자재를 검색하고 선택해주세요."
+ * />
+ * );
+ * ```
+ */
diff --git a/components/common/selectors/material/material-selector.tsx b/components/common/selectors/material/material-selector.tsx
new file mode 100644
index 00000000..0b37efec
--- /dev/null
+++ b/components/common/selectors/material/material-selector.tsx
@@ -0,0 +1,395 @@
+"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 {
+ 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 "./material-service";
+
+interface MaterialSelectorProps {
+ selectedMaterials?: MaterialSearchItem[];
+ onMaterialsChange?: (materials: MaterialSearchItem[]) => void;
+ singleSelect?: boolean;
+ placeholder?: string;
+ noValuePlaceHolder?: string;
+ disabled?: boolean;
+ className?: string;
+ closeOnSelect?: boolean;
+ excludeMaterialCodes?: Set<string>; // 제외할 자재코드들
+ showInitialData?: boolean; // 초기 클릭시 자재들을 로드할지 여부
+ maxSelections?: number; // 최대 선택 가능한 자재 개수 (1이면 단일 선택, undefined면 제한 없음)
+}
+
+export function MaterialSelector({
+ selectedMaterials = [],
+ onMaterialsChange,
+ singleSelect = false,
+ placeholder = "자재코드를 검색하세요...",
+ noValuePlaceHolder = "자재를 검색해주세요",
+ disabled = false,
+ className,
+ closeOnSelect = true,
+ excludeMaterialCodes,
+ showInitialData = true,
+ maxSelections
+}: 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 [initialDataLoaded, setInitialDataLoaded] = useState(false);
+ 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);
+ }
+ }, []);
+
+ // Popover 열림시 초기 데이터 로드
+ React.useEffect(() => {
+ if (open && showInitialData && !initialDataLoaded && !searchQuery.trim()) {
+ setInitialDataLoaded(true);
+ performSearch("", 1); // 빈 쿼리로 초기 데이터 로드
+ }
+ }, [open, showInitialData, initialDataLoaded, searchQuery, performSearch]);
+
+ // Debounced 검색어 변경 시 검색 실행 (검색어가 있을 때만)
+ React.useEffect(() => {
+ if (debouncedSearchQuery.trim()) {
+ setCurrentPage(1);
+ performSearch(debouncedSearchQuery, 1);
+ } else if (showInitialData && initialDataLoaded) {
+ // 검색어가 없고 초기 데이터를 보여주는 경우 초기 데이터 유지
+ // 아무것도 하지 않음 (기존 데이터 유지)
+ } else {
+ // 검색어가 없으면 결과 초기화
+ setSearchResults([]);
+ setPagination({
+ page: 1,
+ perPage: 10,
+ total: 0,
+ pageCount: 0,
+ hasNextPage: false,
+ hasPrevPage: false,
+ });
+ }
+ }, [debouncedSearchQuery, performSearch, showInitialData, initialDataLoaded]);
+
+ // 페이지 변경 처리 - useCallback으로 메모이제이션
+ const handlePageChange = useCallback((newPage: number) => {
+ if (newPage >= 1 && newPage <= pagination.pageCount) {
+ const query = debouncedSearchQuery.trim() || (showInitialData && initialDataLoaded ? "" : debouncedSearchQuery);
+ performSearch(query, newPage);
+ }
+ }, [pagination.pageCount, performSearch, debouncedSearchQuery, showInitialData, initialDataLoaded]);
+
+ // 자재 선택 처리 - useCallback으로 메모이제이션
+ const handleMaterialSelect = useCallback((material: MaterialSearchItem) => {
+ if (disabled) return;
+
+ let newSelectedMaterials: MaterialSearchItem[];
+
+ // maxSelections가 1이면 단일 선택 모드로 동작
+ const isSingleSelectMode = singleSelect || maxSelections === 1;
+
+ if (isSingleSelectMode) {
+ newSelectedMaterials = [material];
+ } else {
+ const isAlreadySelected = selectedMaterials.some(
+ (selected) => selected.materialCode === material.materialCode &&
+ selected.materialName === material.materialName
+ );
+
+ if (isAlreadySelected) {
+ newSelectedMaterials = selectedMaterials.filter(
+ (selected) => !(selected.materialCode === material.materialCode &&
+ selected.materialName === material.materialName)
+ );
+ } else {
+ // 최대 선택 개수 확인
+ if (maxSelections && selectedMaterials.length >= maxSelections) {
+ // 최대 개수에 도달한 경우 선택하지 않음
+ return;
+ }
+ newSelectedMaterials = [...selectedMaterials, material];
+ }
+ }
+
+ onMaterialsChange?.(newSelectedMaterials);
+
+ if (closeOnSelect && isSingleSelectMode) {
+ setOpen(false);
+ }
+ }, [disabled, singleSelect, maxSelections, selectedMaterials, onMaterialsChange, closeOnSelect]);
+
+ // 개별 자재 제거
+ const handleRemoveMaterial = useCallback((materialToRemove: MaterialSearchItem) => {
+ if (disabled) return;
+
+ const newSelectedMaterials = selectedMaterials.filter(
+ (material) => !(material.materialCode === materialToRemove.materialCode &&
+ material.materialName === materialToRemove.materialName)
+ );
+ onMaterialsChange?.(newSelectedMaterials);
+ }, [disabled, selectedMaterials, onMaterialsChange]);
+
+ // 선택된 자재가 있는지 확인
+ const isMaterialSelected = useCallback((material: MaterialSearchItem) => {
+ return selectedMaterials.some(
+ (selected) => selected.materialCode === material.materialCode &&
+ 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.materialCode}-${material.materialName}`}
+ variant="secondary"
+ className="gap-1 pr-1"
+ >
+ <span className="">
+ {material.displayText}
+ </span>
+ {!disabled && (
+ <button
+ type="button"
+ className="ml-1 h-3 w-3 rounded-sm hover:bg-red-100 flex items-center justify-center"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ handleRemoveMaterial(material);
+ }}
+ >
+ <X className="h-3 w-3 hover:text-red-500" />
+ </button>
+ )}
+ </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>
+
+ {/* 스크롤 컨테이너 + 고정 페이지네이션 */}
+ <div className="max-h-[50vh] flex flex-col">
+ <CommandList
+ className="flex-1 overflow-y-auto overflow-x-hidden min-h-0"
+ // shadcn CommandList 버그 처리 - 스크롤 이벤트 전파 차단
+ onWheel={(e) => {
+ e.stopPropagation(); // 이벤트 전파 차단
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY; // 직접 스크롤 처리
+ }}
+ >
+ {!searchQuery.trim() && !showInitialData ? (
+ <div className="p-4 text-center text-sm text-muted-foreground">
+ 자재코드를 검색하려면 검색어를 입력해주세요.
+ </div>
+ ) : !searchQuery.trim() && showInitialData && !initialDataLoaded ? (
+ <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 className="overflow-visible">
+ {searchResults.map((material) => {
+ const isExcluded = excludeMaterialCodes?.has(material.materialCode);
+ const isSelected = isMaterialSelected(material);
+ const isMaxReached = maxSelections && selectedMaterials.length >= maxSelections && !isSelected;
+ const isDisabled = isExcluded || isMaxReached;
+
+ return (
+ <CommandItem
+ key={`${material.materialCode}-${material.materialName}`}
+ onSelect={() => {
+ if (!isDisabled) {
+ handleMaterialSelect(material);
+ }
+ }}
+ className={cn(
+ "cursor-pointer",
+ isDisabled && "opacity-50 cursor-not-allowed bg-muted"
+ )}
+ >
+ <div className="mr-2 h-4 w-4 flex items-center justify-center">
+ {isExcluded ? (
+ <span className="text-xs text-muted-foreground">✓</span>
+ ) : isMaxReached ? (
+ <span className="text-xs text-muted-foreground">-</span>
+ ) : (
+ <Check
+ className={cn(
+ "h-4 w-4",
+ isSelected ? "opacity-100" : "opacity-0"
+ )}
+ />
+ )}
+ </div>
+ <div className="flex-1">
+ <div className={cn(
+ "font-medium",
+ isDisabled && "text-muted-foreground"
+ )}>
+ {material.materialName}
+ {isExcluded && (
+ <span className="ml-2 text-xs bg-red-100 text-red-600 px-2 py-1 rounded">
+ 이미 등록됨
+ </span>
+ )}
+ {isMaxReached && (
+ <span className="ml-2 text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded">
+ 선택 제한 ({maxSelections}개)
+ </span>
+ )}
+ </div>
+ <div className="text-xs text-muted-foreground">
+ 자재코드: {material.materialCode}
+ </div>
+ </div>
+ </CommandItem>
+ );
+ })}
+ </CommandGroup>
+ )}
+ </CommandList>
+
+ {/* 고정 페이지네이션 - 항상 밑에 표시 */}
+ {searchResults.length > 0 && pagination.pageCount > 1 && (
+ <div className="flex items-center justify-between border-t px-3 py-2 flex-shrink-0">
+ <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>
+ )}
+ </div>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </div>
+ );
+}
diff --git a/components/common/selectors/material/material-service.ts b/components/common/selectors/material/material-service.ts
new file mode 100644
index 00000000..1f238ed7
--- /dev/null
+++ b/components/common/selectors/material/material-service.ts
@@ -0,0 +1,155 @@
+"use server";
+
+import { sql, SQL } from "drizzle-orm";
+import db from "@/db/db";
+import { MATERIAL_MASTER_PART_MATL } from "@/db/schema/MDG/mdg";
+
+export interface MaterialSearchItem {
+ materialCode: string; // 자재코드 (MATNR)
+ materialName: string; // 자재명 (ZZNAME)
+ displayText: string; // 애플리케이션 레벨에서 계산된 필드
+}
+
+export interface MaterialSearchResult {
+ success: boolean;
+ data: MaterialSearchItem[];
+ pagination: {
+ page: number;
+ perPage: number;
+ total: number;
+ pageCount: number;
+ hasNextPage: boolean;
+ hasPrevPage: boolean;
+ };
+}
+
+/**
+ * 자재 검색 함수 - MATERIAL_MASTER_PART_MATL 테이블에서 검색 (페이지네이션)
+ * 사용자는 자재코드를 직접 입력하여 검색함 (자재명이 아닌 자재코드만 검색 대상)
+ * 자재 레코드가 수억개 수준이므로 항상 페이지네이션 사용
+ */
+export async function searchMaterialsForSelector(
+ query: string,
+ page: number = 1,
+ perPage: number = 10
+): Promise<MaterialSearchResult> {
+ try {
+ const offset = (page - 1) * perPage;
+
+ // 검색 조건 - 자재코드(MATNR)로만 검색
+ let searchWhere: SQL<unknown> | undefined;
+ if (query.trim()) {
+ const searchPattern = `%${query.trim()}%`;
+ searchWhere = sql`${MATERIAL_MASTER_PART_MATL.MATNR} ILIKE ${searchPattern}`;
+ }
+
+ const { data, total } = await db.transaction(async (tx) => {
+ // 데이터 조회 - MATNR과 ZZNAME만 선택
+ const data = await tx
+ .select({
+ materialCode: MATERIAL_MASTER_PART_MATL.MATNR,
+ materialName: MATERIAL_MASTER_PART_MATL.ZZNAME,
+ })
+ .from(MATERIAL_MASTER_PART_MATL)
+ .where(searchWhere)
+ .orderBy(MATERIAL_MASTER_PART_MATL.MATNR)
+ .limit(perPage)
+ .offset(offset);
+
+ // 총 개수 조회
+ const countResult = await tx
+ .select({ count: sql<number>`count(*)` })
+ .from(MATERIAL_MASTER_PART_MATL)
+ .where(searchWhere);
+
+ const total = countResult[0]?.count || 0;
+
+ return {
+ data: data.map((row) => ({
+ materialCode: row.materialCode || '',
+ materialName: row.materialName || '',
+ displayText: `${row.materialCode || ''} - ${row.materialName || ''}`, // 애플리케이션 레벨에서 생성
+ })),
+ total,
+ };
+ });
+
+ const pageCount = Math.ceil(total / perPage);
+
+ return {
+ success: true,
+ data,
+ pagination: {
+ page,
+ perPage,
+ total,
+ pageCount,
+ hasNextPage: page < pageCount,
+ hasPrevPage: page > 1,
+ },
+ };
+ } catch (error) {
+ console.error("자재 검색 오류:", error);
+ return {
+ success: false,
+ data: [],
+ pagination: {
+ page: 1,
+ perPage: 10,
+ total: 0,
+ pageCount: 0,
+ hasNextPage: false,
+ hasPrevPage: false,
+ },
+ };
+ }
+}
+
+/**
+ * Dialog용 자재 목록 조회 함수 - 페이지네이션
+ * Dialog에서는 효율적인 사용자 경험을 위해 10개씩 페이지네이션하여 조회
+ * 자재 레코드가 수억개 수준이므로 무한 스크롤이나 전체 로드가 아닌 페이지네이션 사용
+ */
+export async function searchMaterialsForDialog(
+ query: string,
+ page: number = 1,
+ perPage: number = 10
+): Promise<{
+ success: boolean;
+ data: MaterialSearchItem[];
+ pagination: {
+ page: number;
+ perPage: number;
+ total: number;
+ pageCount: number;
+ hasNextPage: boolean;
+ hasPrevPage: boolean;
+ };
+ error?: string;
+}> {
+ try {
+ const result = await searchMaterialsForSelector(query, page, perPage);
+
+ return {
+ success: result.success,
+ data: result.data,
+ pagination: result.pagination,
+ error: result.success ? undefined : "검색 중 오류가 발생했습니다.",
+ };
+ } catch (error) {
+ console.error("Dialog용 자재 검색 오류:", error);
+ return {
+ success: false,
+ data: [],
+ pagination: {
+ page: 1,
+ perPage: 10,
+ total: 0,
+ pageCount: 0,
+ hasNextPage: false,
+ hasPrevPage: false,
+ },
+ error: "데이터를 불러오는데 실패했습니다.",
+ };
+ }
+} \ No newline at end of file