summaryrefslogtreecommitdiff
path: root/components/common/selectors/material/material-selector-dialog-multi.tsx
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-10-14 20:27:47 +0900
committerjoonhoekim <26rote@gmail.com>2025-10-14 20:27:47 +0900
commitf63cf682d6846210a04ce4a3eb8ebe9afd6d6dba (patch)
tree0a4717c11814c45e2b7d6531723700b1ae0a4974 /components/common/selectors/material/material-selector-dialog-multi.tsx
parentd1cdcf9f35eca0552d1011e6d3c11a1d2d9abee4 (diff)
(김준회) 자재코드 선택기 구현 및 일반견적 생성시 반영
Diffstat (limited to 'components/common/selectors/material/material-selector-dialog-multi.tsx')
-rw-r--r--components/common/selectors/material/material-selector-dialog-multi.tsx538
1 files changed, 538 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}
+ * />
+ * ```
+ */