feat: Implement advanced filtering capabilities in entity search
- Added a new helper function `applyFilters` to handle dynamic filter conditions for entity search queries. - Enhanced the `getDistinctColumnValues` and `getEntityOptions` endpoints to support JSON array filters, allowing for more flexible data retrieval based on specified conditions. - Updated the frontend components to integrate filter conditions, improving user interaction and data management in selection components. - Introduced new filter options in the V2Select component, enabling users to define and apply various filter criteria dynamically.
This commit is contained in:
@@ -5,15 +5,16 @@
|
||||
* 통합 선택 컴포넌트의 세부 설정을 관리합니다.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Trash2, Loader2 } from "lucide-react";
|
||||
import { Plus, Trash2, Loader2, Filter } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import type { V2SelectFilter } from "@/types/v2-components";
|
||||
|
||||
interface ColumnOption {
|
||||
columnName: string;
|
||||
@@ -25,6 +26,238 @@ interface CategoryValueOption {
|
||||
valueLabel: string;
|
||||
}
|
||||
|
||||
const OPERATOR_OPTIONS = [
|
||||
{ value: "=", label: "같음 (=)" },
|
||||
{ value: "!=", label: "다름 (!=)" },
|
||||
{ value: ">", label: "초과 (>)" },
|
||||
{ value: "<", label: "미만 (<)" },
|
||||
{ value: ">=", label: "이상 (>=)" },
|
||||
{ value: "<=", label: "이하 (<=)" },
|
||||
{ value: "in", label: "포함 (IN)" },
|
||||
{ value: "notIn", label: "미포함 (NOT IN)" },
|
||||
{ value: "like", label: "유사 (LIKE)" },
|
||||
{ value: "isNull", label: "NULL" },
|
||||
{ value: "isNotNull", label: "NOT NULL" },
|
||||
] as const;
|
||||
|
||||
const VALUE_TYPE_OPTIONS = [
|
||||
{ value: "static", label: "고정값" },
|
||||
{ value: "field", label: "폼 필드 참조" },
|
||||
{ value: "user", label: "로그인 사용자" },
|
||||
] as const;
|
||||
|
||||
const USER_FIELD_OPTIONS = [
|
||||
{ value: "companyCode", label: "회사코드" },
|
||||
{ value: "userId", label: "사용자ID" },
|
||||
{ value: "deptCode", label: "부서코드" },
|
||||
{ value: "userName", label: "사용자명" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 필터 조건 설정 서브 컴포넌트
|
||||
*/
|
||||
const FilterConditionsSection: React.FC<{
|
||||
filters: V2SelectFilter[];
|
||||
columns: ColumnOption[];
|
||||
loadingColumns: boolean;
|
||||
targetTable: string;
|
||||
onFiltersChange: (filters: V2SelectFilter[]) => void;
|
||||
}> = ({ filters, columns, loadingColumns, targetTable, onFiltersChange }) => {
|
||||
|
||||
const addFilter = () => {
|
||||
onFiltersChange([
|
||||
...filters,
|
||||
{ column: "", operator: "=", valueType: "static", value: "" },
|
||||
]);
|
||||
};
|
||||
|
||||
const updateFilter = (index: number, patch: Partial<V2SelectFilter>) => {
|
||||
const updated = [...filters];
|
||||
updated[index] = { ...updated[index], ...patch };
|
||||
|
||||
// valueType 변경 시 관련 필드 초기화
|
||||
if (patch.valueType) {
|
||||
if (patch.valueType === "static") {
|
||||
updated[index].fieldRef = undefined;
|
||||
updated[index].userField = undefined;
|
||||
} else if (patch.valueType === "field") {
|
||||
updated[index].value = undefined;
|
||||
updated[index].userField = undefined;
|
||||
} else if (patch.valueType === "user") {
|
||||
updated[index].value = undefined;
|
||||
updated[index].fieldRef = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// isNull/isNotNull 연산자는 값 불필요
|
||||
if (patch.operator === "isNull" || patch.operator === "isNotNull") {
|
||||
updated[index].value = undefined;
|
||||
updated[index].fieldRef = undefined;
|
||||
updated[index].userField = undefined;
|
||||
updated[index].valueType = "static";
|
||||
}
|
||||
|
||||
onFiltersChange(updated);
|
||||
};
|
||||
|
||||
const removeFilter = (index: number) => {
|
||||
onFiltersChange(filters.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const needsValue = (op: string) => op !== "isNull" && op !== "isNotNull";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Label className="text-xs font-medium">데이터 필터 조건</Label>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addFilter}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
{targetTable} 테이블에서 옵션을 불러올 때 적용할 조건
|
||||
</p>
|
||||
|
||||
{loadingColumns && (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
컬럼 목록 로딩 중...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filters.length === 0 && (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">
|
||||
필터 조건이 없습니다
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{filters.map((filter, index) => (
|
||||
<div key={index} className="space-y-1.5 rounded-md border p-2">
|
||||
{/* 행 1: 컬럼 + 연산자 + 삭제 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* 컬럼 선택 */}
|
||||
<Select
|
||||
value={filter.column || ""}
|
||||
onValueChange={(v) => updateFilter(index, { column: v })}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-[11px]">
|
||||
<SelectValue placeholder="컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<Select
|
||||
value={filter.operator || "="}
|
||||
onValueChange={(v) => updateFilter(index, { operator: v as V2SelectFilter["operator"] })}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[90px] shrink-0 text-[11px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATOR_OPTIONS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeFilter(index)}
|
||||
className="text-destructive h-7 w-7 shrink-0 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 행 2: 값 유형 + 값 입력 (isNull/isNotNull 제외) */}
|
||||
{needsValue(filter.operator) && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* 값 유형 */}
|
||||
<Select
|
||||
value={filter.valueType || "static"}
|
||||
onValueChange={(v) => updateFilter(index, { valueType: v as V2SelectFilter["valueType"] })}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[100px] shrink-0 text-[11px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VALUE_TYPE_OPTIONS.map((vt) => (
|
||||
<SelectItem key={vt.value} value={vt.value}>
|
||||
{vt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 값 입력 영역 */}
|
||||
{(filter.valueType || "static") === "static" && (
|
||||
<Input
|
||||
value={String(filter.value ?? "")}
|
||||
onChange={(e) => updateFilter(index, { value: e.target.value })}
|
||||
placeholder={filter.operator === "in" || filter.operator === "notIn" ? "값1, 값2, ..." : "값 입력"}
|
||||
className="h-7 flex-1 text-[11px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{filter.valueType === "field" && (
|
||||
<Input
|
||||
value={filter.fieldRef || ""}
|
||||
onChange={(e) => updateFilter(index, { fieldRef: e.target.value })}
|
||||
placeholder="참조할 필드명 (columnName)"
|
||||
className="h-7 flex-1 text-[11px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{filter.valueType === "user" && (
|
||||
<Select
|
||||
value={filter.userField || ""}
|
||||
onValueChange={(v) => updateFilter(index, { userField: v as V2SelectFilter["userField"] })}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-[11px]">
|
||||
<SelectValue placeholder="사용자 필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{USER_FIELD_OPTIONS.map((uf) => (
|
||||
<SelectItem key={uf.value} value={uf.value}>
|
||||
{uf.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface V2SelectConfigPanelProps {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
@@ -53,10 +286,52 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
||||
const [categoryValues, setCategoryValues] = useState<CategoryValueOption[]>([]);
|
||||
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
|
||||
|
||||
// 필터용 컬럼 목록 (옵션 데이터 소스 테이블의 컬럼)
|
||||
const [filterColumns, setFilterColumns] = useState<ColumnOption[]>([]);
|
||||
const [loadingFilterColumns, setLoadingFilterColumns] = useState(false);
|
||||
|
||||
const updateConfig = (field: string, value: any) => {
|
||||
onChange({ ...config, [field]: value });
|
||||
};
|
||||
|
||||
// 필터 대상 테이블 결정
|
||||
const filterTargetTable = useMemo(() => {
|
||||
const src = config.source || "static";
|
||||
if (src === "entity") return config.entityTable;
|
||||
if (src === "db") return config.table;
|
||||
if (src === "distinct" || src === "select") return tableName;
|
||||
return null;
|
||||
}, [config.source, config.entityTable, config.table, tableName]);
|
||||
|
||||
// 필터 대상 테이블의 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (!filterTargetTable) {
|
||||
setFilterColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadFilterColumns = async () => {
|
||||
setLoadingFilterColumns(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${filterTargetTable}/columns?size=500`);
|
||||
const data = response.data.data || response.data;
|
||||
const columns = data.columns || data || [];
|
||||
setFilterColumns(
|
||||
columns.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name || col.name,
|
||||
columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name,
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
setFilterColumns([]);
|
||||
} finally {
|
||||
setLoadingFilterColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadFilterColumns();
|
||||
}, [filterTargetTable]);
|
||||
|
||||
// 카테고리 타입이면 source를 자동으로 category로 설정
|
||||
useEffect(() => {
|
||||
if (isCategoryType && config.source !== "category") {
|
||||
@@ -518,6 +793,20 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 필터 조건 - static 소스 외 모든 소스에서 사용 */}
|
||||
{effectiveSource !== "static" && filterTargetTable && (
|
||||
<>
|
||||
<Separator />
|
||||
<FilterConditionsSection
|
||||
filters={(config.filters as V2SelectFilter[]) || []}
|
||||
columns={filterColumns}
|
||||
loadingColumns={loadingFilterColumns}
|
||||
targetTable={filterTargetTable}
|
||||
onFiltersChange={(filters) => updateConfig("filters", filters)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user