349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useEffect } from "react";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { Search, Loader2 } from "lucide-react";
|
||
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
|
||
import { ItemSelectionModalProps } from "./types";
|
||
|
||
export function ItemSelectionModal({
|
||
open,
|
||
onOpenChange,
|
||
sourceTable,
|
||
sourceColumns,
|
||
sourceSearchFields = [],
|
||
multiSelect = true,
|
||
filterCondition = {},
|
||
modalTitle,
|
||
alreadySelected = [],
|
||
uniqueField,
|
||
onSelect,
|
||
columnLabels = {},
|
||
}: ItemSelectionModalProps) {
|
||
const [localSearchText, setLocalSearchText] = useState("");
|
||
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
||
|
||
const { results, loading, error, search, clearSearch } = useEntitySearch({
|
||
tableName: sourceTable,
|
||
searchFields: sourceSearchFields.length > 0 ? sourceSearchFields : sourceColumns,
|
||
filterCondition,
|
||
});
|
||
|
||
// 모달 열릴 때 초기 검색
|
||
useEffect(() => {
|
||
if (open) {
|
||
console.log("🚪 모달 열림 - uniqueField:", uniqueField, "multiSelect:", multiSelect);
|
||
search("", 1); // 빈 검색어로 전체 목록 조회
|
||
setSelectedItems([]);
|
||
} else {
|
||
clearSearch();
|
||
setLocalSearchText("");
|
||
setSelectedItems([]);
|
||
}
|
||
}, [open]);
|
||
|
||
const handleSearch = () => {
|
||
search(localSearchText, 1);
|
||
};
|
||
|
||
const handleToggleItem = (item: any) => {
|
||
const itemValue = uniqueField ? item[uniqueField] : undefined;
|
||
|
||
console.log("🖱️ 행 클릭:", {
|
||
item,
|
||
uniqueField,
|
||
itemValue,
|
||
currentSelected: selectedItems.length,
|
||
selectedValues: uniqueField ? selectedItems.map(s => s[uniqueField]) : []
|
||
});
|
||
|
||
if (!multiSelect) {
|
||
setSelectedItems([item]);
|
||
return;
|
||
}
|
||
|
||
// uniqueField 값이 undefined인 경우 객체 참조로 비교
|
||
if (uniqueField && (itemValue === undefined || itemValue === null)) {
|
||
console.warn(`⚠️ uniqueField "${uniqueField}"의 값이 undefined입니다. 객체 참조로 비교합니다.`);
|
||
const itemIsSelected = selectedItems.includes(item);
|
||
|
||
console.log("📊 선택 상태 (객체 참조):", itemIsSelected);
|
||
|
||
if (itemIsSelected) {
|
||
const newSelected = selectedItems.filter((selected) => selected !== item);
|
||
console.log("➖ 제거 후:", newSelected.length);
|
||
setSelectedItems(newSelected);
|
||
} else {
|
||
console.log("➕ 추가");
|
||
setSelectedItems([...selectedItems, item]);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const itemIsSelected = selectedItems.some((selected) => {
|
||
if (!uniqueField) {
|
||
return selected === item;
|
||
}
|
||
const selectedValue = selected[uniqueField];
|
||
if (selectedValue === undefined || selectedValue === null) {
|
||
return false;
|
||
}
|
||
return selectedValue === itemValue;
|
||
});
|
||
|
||
console.log("📊 선택 상태:", itemIsSelected);
|
||
|
||
if (itemIsSelected) {
|
||
const newSelected = selectedItems.filter((selected) => {
|
||
if (!uniqueField) {
|
||
return selected !== item;
|
||
}
|
||
const selectedValue = selected[uniqueField];
|
||
if (selectedValue === undefined || selectedValue === null) {
|
||
return true;
|
||
}
|
||
return selectedValue !== itemValue;
|
||
});
|
||
console.log("➖ 제거 후:", newSelected.length);
|
||
setSelectedItems(newSelected);
|
||
} else {
|
||
console.log("➕ 추가");
|
||
setSelectedItems([...selectedItems, item]);
|
||
}
|
||
};
|
||
|
||
const handleConfirm = () => {
|
||
onSelect(selectedItems);
|
||
onOpenChange(false);
|
||
};
|
||
|
||
// 이미 추가된 항목인지 확인
|
||
const isAlreadyAdded = (item: any): boolean => {
|
||
if (!uniqueField) return false;
|
||
return alreadySelected.some(
|
||
(selected) => selected[uniqueField] === item[uniqueField]
|
||
);
|
||
};
|
||
|
||
// 이미 추가된 항목 제외한 결과 필터링
|
||
const filteredResults = results.filter((item) => !isAlreadyAdded(item));
|
||
|
||
// 선택된 항목인지 확인
|
||
const isSelected = (item: any): boolean => {
|
||
if (!uniqueField) {
|
||
return selectedItems.includes(item);
|
||
}
|
||
|
||
const itemValue = item[uniqueField];
|
||
|
||
// uniqueField 값이 undefined인 경우 객체 참조로 비교
|
||
if (itemValue === undefined || itemValue === null) {
|
||
return selectedItems.includes(item);
|
||
}
|
||
|
||
const result = selectedItems.some((selected) => {
|
||
const selectedValue = selected[uniqueField];
|
||
|
||
// selectedValue도 undefined면 안전하게 처리
|
||
if (selectedValue === undefined || selectedValue === null) {
|
||
return false;
|
||
}
|
||
|
||
const isMatch = selectedValue === itemValue;
|
||
|
||
if (isMatch) {
|
||
console.log("✅ 매칭 발견:", {
|
||
selectedValue,
|
||
itemValue,
|
||
uniqueField
|
||
});
|
||
}
|
||
|
||
return isMatch;
|
||
});
|
||
|
||
return result;
|
||
};
|
||
|
||
// 유효한 컬럼만 필터링
|
||
const validColumns = sourceColumns.filter(col => col != null && col !== "");
|
||
const totalColumns = validColumns.length + (multiSelect ? 1 : 0);
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-w-[95vw] sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-base sm:text-lg">{modalTitle}</DialogTitle>
|
||
<DialogDescription className="text-xs sm:text-sm">
|
||
항목을 검색하고 선택하세요
|
||
{multiSelect && " (다중 선택 가능)"}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{/* 검색 입력 */}
|
||
<div className="flex gap-2">
|
||
<Input
|
||
placeholder="검색어를 입력하세요"
|
||
value={localSearchText}
|
||
onChange={(e) => setLocalSearchText(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
handleSearch();
|
||
}
|
||
}}
|
||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||
/>
|
||
<Button
|
||
onClick={handleSearch}
|
||
disabled={loading}
|
||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||
>
|
||
{loading ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Search className="h-4 w-4" />
|
||
)}
|
||
<span className="ml-2">검색</span>
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 선택된 항목 수 */}
|
||
{selectedItems.length > 0 && (
|
||
<div className="text-sm text-primary">
|
||
{selectedItems.length}개 항목 선택됨
|
||
{uniqueField && (
|
||
<span className="ml-2 text-xs text-muted-foreground">
|
||
({selectedItems.map(item => item[uniqueField]).join(", ")})
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 오류 메시지 */}
|
||
{error && (
|
||
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* 검색 결과 테이블 */}
|
||
<div className="border rounded-md overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-xs sm:text-sm">
|
||
<thead className="bg-muted">
|
||
<tr>
|
||
{multiSelect && (
|
||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
|
||
선택
|
||
</th>
|
||
)}
|
||
{validColumns.map((col) => (
|
||
<th
|
||
key={col}
|
||
className="px-4 py-2 text-left font-medium text-muted-foreground"
|
||
>
|
||
{columnLabels[col] || col}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{loading && filteredResults.length === 0 ? (
|
||
<tr>
|
||
<td
|
||
colSpan={totalColumns}
|
||
className="px-4 py-8 text-center"
|
||
>
|
||
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
||
<p className="mt-2 text-muted-foreground">검색 중...</p>
|
||
</td>
|
||
</tr>
|
||
) : filteredResults.length === 0 ? (
|
||
<tr>
|
||
<td
|
||
colSpan={totalColumns}
|
||
className="px-4 py-8 text-center text-muted-foreground"
|
||
>
|
||
{results.length > 0
|
||
? "모든 항목이 이미 추가되었습니다"
|
||
: "검색 결과가 없습니다"}
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
filteredResults.map((item, index) => {
|
||
const selected = isSelected(item);
|
||
const uniqueFieldValue = uniqueField ? item[uniqueField] : undefined;
|
||
const itemKey = (uniqueFieldValue !== undefined && uniqueFieldValue !== null)
|
||
? uniqueFieldValue
|
||
: `item-${index}`;
|
||
|
||
return (
|
||
<tr
|
||
key={itemKey}
|
||
className={`border-t transition-colors ${
|
||
selected
|
||
? "bg-primary/10"
|
||
: "hover:bg-accent cursor-pointer"
|
||
}`}
|
||
onClick={() => handleToggleItem(item)}
|
||
>
|
||
{multiSelect && (
|
||
<td
|
||
className="px-4 py-2"
|
||
onClick={(e) => {
|
||
// 체크박스 영역 클릭을 행 클릭으로 전파
|
||
e.stopPropagation();
|
||
handleToggleItem(item);
|
||
}}
|
||
>
|
||
<div className="pointer-events-none">
|
||
<Checkbox checked={selected} readOnly />
|
||
</div>
|
||
</td>
|
||
)}
|
||
{validColumns.map((col) => (
|
||
<td key={col} className="px-4 py-2">
|
||
{item[col] || "-"}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter className="gap-2 sm:gap-0">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => onOpenChange(false)}
|
||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||
>
|
||
취소
|
||
</Button>
|
||
<Button
|
||
onClick={handleConfirm}
|
||
disabled={selectedItems.length === 0}
|
||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||
>
|
||
추가 ({selectedItems.length})
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|