554 lines
19 KiB
TypeScript
554 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useMemo } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Search, Loader2 } from "lucide-react";
|
|
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
|
|
import { ItemSelectionModalProps, ModalFilterConfig } from "./types";
|
|
import { apiClient } from "@/lib/api/client";
|
|
|
|
export function ItemSelectionModal({
|
|
open,
|
|
onOpenChange,
|
|
sourceTable,
|
|
sourceColumns,
|
|
sourceSearchFields = [],
|
|
multiSelect = true,
|
|
filterCondition = {},
|
|
modalTitle,
|
|
alreadySelected = [],
|
|
uniqueField,
|
|
onSelect,
|
|
columnLabels = {},
|
|
modalFilters = [],
|
|
categoryColumns = [],
|
|
}: ItemSelectionModalProps) {
|
|
const [localSearchText, setLocalSearchText] = useState("");
|
|
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
|
|
|
// 모달 필터 값 상태
|
|
const [modalFilterValues, setModalFilterValues] = useState<Record<string, any>>({});
|
|
|
|
// 카테고리 옵션 상태 (categoryRef별로 로드된 옵션)
|
|
const [categoryOptions, setCategoryOptions] = useState<Record<string, { value: string; label: string }[]>>({});
|
|
|
|
// 카테고리 코드 → 라벨 매핑 (테이블 데이터 표시용)
|
|
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
|
|
|
|
// 모달 필터 값과 기본 filterCondition을 합친 최종 필터 조건
|
|
const combinedFilterCondition = useMemo(() => {
|
|
const combined = { ...filterCondition };
|
|
|
|
// 모달 필터 값 추가 (빈 값은 제외)
|
|
for (const [key, value] of Object.entries(modalFilterValues)) {
|
|
if (value !== undefined && value !== null && value !== "") {
|
|
combined[key] = value;
|
|
}
|
|
}
|
|
|
|
return combined;
|
|
}, [filterCondition, modalFilterValues]);
|
|
|
|
const { results, loading, error, search, clearSearch } = useEntitySearch({
|
|
tableName: sourceTable,
|
|
searchFields: sourceSearchFields.length > 0 ? sourceSearchFields : sourceColumns,
|
|
filterCondition: combinedFilterCondition,
|
|
});
|
|
|
|
// 필터 옵션 로드 - 소스 테이블 컬럼의 distinct 값 조회
|
|
const loadFilterOptions = async (filter: ModalFilterConfig) => {
|
|
// 드롭다운 타입만 옵션 로드 필요 (select, category 지원)
|
|
const isDropdownType = filter.type === "select" || filter.type === "category";
|
|
if (!isDropdownType) return;
|
|
|
|
const cacheKey = `${sourceTable}.${filter.column}`;
|
|
|
|
// 이미 로드된 경우 스킵
|
|
if (categoryOptions[cacheKey]) return;
|
|
|
|
try {
|
|
// 소스 테이블에서 해당 컬럼의 데이터 조회 (POST 메서드 사용)
|
|
// 백엔드는 'size' 파라미터를 사용함
|
|
const response = await apiClient.post(`/table-management/tables/${sourceTable}/data`, {
|
|
page: 1,
|
|
size: 10000, // 모든 데이터 조회를 위해 큰 값 설정
|
|
});
|
|
|
|
if (response.data?.success) {
|
|
// 응답 구조에 따라 rows 추출
|
|
const rows = response.data.data?.rows || response.data.data?.data || response.data.data || [];
|
|
|
|
if (Array.isArray(rows)) {
|
|
// 컬럼 값 중복 제거
|
|
const uniqueValues = new Set<string>();
|
|
for (const row of rows) {
|
|
const val = row[filter.column];
|
|
if (val !== null && val !== undefined && val !== "") {
|
|
uniqueValues.add(String(val));
|
|
}
|
|
}
|
|
|
|
// 정렬 후 옵션으로 변환
|
|
const options = Array.from(uniqueValues)
|
|
.sort()
|
|
.map((val) => ({
|
|
value: val,
|
|
label: val,
|
|
}));
|
|
|
|
setCategoryOptions((prev) => ({
|
|
...prev,
|
|
[cacheKey]: options,
|
|
}));
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`필터 옵션 로드 실패 (${cacheKey}):`, error);
|
|
setCategoryOptions((prev) => ({
|
|
...prev,
|
|
[cacheKey]: [],
|
|
}));
|
|
}
|
|
};
|
|
|
|
// 모달 열릴 때 초기 검색 및 필터 초기화
|
|
useEffect(() => {
|
|
if (open) {
|
|
// 모달 필터 기본값 설정 & 옵션 로드
|
|
const initialFilterValues: Record<string, any> = {};
|
|
for (const filter of modalFilters) {
|
|
if (filter.defaultValue !== undefined) {
|
|
initialFilterValues[filter.column] = filter.defaultValue;
|
|
}
|
|
// 드롭다운 타입이면 옵션 로드 (소스 테이블에서 distinct 값 조회)
|
|
const isDropdownType = filter.type === "select" || filter.type === "category";
|
|
if (isDropdownType) {
|
|
loadFilterOptions(filter);
|
|
}
|
|
}
|
|
setModalFilterValues(initialFilterValues);
|
|
|
|
search("", 1); // 빈 검색어로 전체 목록 조회
|
|
setSelectedItems([]);
|
|
} else {
|
|
clearSearch();
|
|
setLocalSearchText("");
|
|
setSelectedItems([]);
|
|
setModalFilterValues({});
|
|
}
|
|
}, [open]);
|
|
|
|
// 모달 필터 값 변경 시 재검색
|
|
useEffect(() => {
|
|
if (open) {
|
|
search(localSearchText, 1);
|
|
}
|
|
}, [modalFilterValues]);
|
|
|
|
// 검색 결과가 변경되면 카테고리 값들의 라벨 조회
|
|
useEffect(() => {
|
|
const loadCategoryLabels = async () => {
|
|
if (!open || categoryColumns.length === 0 || results.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// 현재 결과에서 카테고리 컬럼의 모든 고유한 값 수집
|
|
// 쉼표로 구분된 다중 값도 개별적으로 수집
|
|
const allCodes = new Set<string>();
|
|
for (const row of results) {
|
|
for (const col of categoryColumns) {
|
|
const val = row[col];
|
|
if (val && typeof val === "string") {
|
|
// 쉼표로 구분된 다중 값 처리
|
|
const codes = val.split(",").map((c) => c.trim()).filter(Boolean);
|
|
for (const code of codes) {
|
|
if (!categoryLabelMap[code]) {
|
|
allCodes.add(code);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (allCodes.size === 0) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await apiClient.post("/table-categories/labels-by-codes", {
|
|
valueCodes: Array.from(allCodes),
|
|
});
|
|
|
|
if (response.data?.success && response.data.data) {
|
|
setCategoryLabelMap((prev) => ({
|
|
...prev,
|
|
...response.data.data,
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
console.error("카테고리 라벨 조회 실패:", error);
|
|
}
|
|
};
|
|
|
|
loadCategoryLabels();
|
|
}, [open, results, categoryColumns]);
|
|
|
|
// 모달 필터 값 변경 핸들러
|
|
const handleModalFilterChange = (column: string, value: any) => {
|
|
setModalFilterValues((prev) => ({
|
|
...prev,
|
|
[column]: value,
|
|
}));
|
|
};
|
|
|
|
const handleSearch = () => {
|
|
search(localSearchText, 1);
|
|
};
|
|
|
|
const handleToggleItem = (item: any) => {
|
|
const itemValue = uniqueField ? item[uniqueField] : undefined;
|
|
|
|
if (!multiSelect) {
|
|
setSelectedItems([item]);
|
|
return;
|
|
}
|
|
|
|
// uniqueField 값이 undefined인 경우 객체 참조로 비교
|
|
if (uniqueField && (itemValue === undefined || itemValue === null)) {
|
|
console.warn(`⚠️ uniqueField "${uniqueField}"의 값이 undefined입니다. 객체 참조로 비교합니다.`);
|
|
const itemIsSelected = selectedItems.includes(item);
|
|
|
|
if (itemIsSelected) {
|
|
const newSelected = selectedItems.filter((selected) => selected !== item);
|
|
setSelectedItems(newSelected);
|
|
} else {
|
|
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;
|
|
});
|
|
|
|
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;
|
|
});
|
|
setSelectedItems(newSelected);
|
|
} else {
|
|
setSelectedItems([...selectedItems, item]);
|
|
}
|
|
};
|
|
|
|
const handleConfirm = () => {
|
|
console.log("✅ ItemSelectionModal 추가:", selectedItems.length, "개 항목");
|
|
|
|
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] flex flex-col">
|
|
<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>
|
|
|
|
{/* 모달 필터 */}
|
|
{modalFilters.length > 0 && (
|
|
<div className="flex flex-wrap gap-3 items-center py-2 px-1 bg-muted/30 rounded-md">
|
|
{modalFilters.map((filter) => {
|
|
// 소스 테이블의 해당 컬럼에서 로드된 옵션
|
|
const options = categoryOptions[`${sourceTable}.${filter.column}`] || [];
|
|
|
|
// 드롭다운 타입인지 확인 (select, category 모두 드롭다운으로 처리)
|
|
const isDropdownType = filter.type === "select" || filter.type === "category";
|
|
|
|
return (
|
|
<div key={filter.column} className="flex items-center gap-2">
|
|
<span className="text-xs font-medium text-muted-foreground">{filter.label}:</span>
|
|
{isDropdownType && (
|
|
<Select
|
|
value={modalFilterValues[filter.column] || "__all__"}
|
|
onValueChange={(value) => handleModalFilterChange(filter.column, value === "__all__" ? "" : value)}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs w-[140px]">
|
|
<SelectValue placeholder="전체" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__all__">전체</SelectItem>
|
|
{options.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value || `__empty_${opt.label}__`}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
{filter.type === "text" && (
|
|
<Input
|
|
value={modalFilterValues[filter.column] || ""}
|
|
onChange={(e) => handleModalFilterChange(filter.column, e.target.value)}
|
|
placeholder={filter.label}
|
|
className="h-7 text-xs w-[120px]"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</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 flex-1 min-h-0">
|
|
<div className="overflow-auto max-h-[50vh]">
|
|
<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} />
|
|
</div>
|
|
</td>
|
|
)}
|
|
{validColumns.map((col) => {
|
|
const rawValue = item[col];
|
|
// 카테고리 컬럼이면 라벨로 변환
|
|
const isCategory = categoryColumns.includes(col);
|
|
let displayValue = rawValue;
|
|
|
|
if (isCategory && rawValue && typeof rawValue === "string") {
|
|
// 쉼표로 구분된 다중 값 처리
|
|
const codes = rawValue.split(",").map((c) => c.trim()).filter(Boolean);
|
|
const labels = codes.map((code) => categoryLabelMap[code] || code);
|
|
displayValue = labels.join(", ");
|
|
}
|
|
|
|
return (
|
|
<td key={col} className="px-4 py-2">
|
|
{displayValue || "-"}
|
|
</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>
|
|
);
|
|
}
|
|
|