검색필터 다중선택 기능

This commit is contained in:
kjs 2025-12-01 10:36:57 +09:00
parent 142fb15dc0
commit a3d3db5437
3 changed files with 104 additions and 38 deletions

View File

@ -1165,12 +1165,26 @@ export class TableManagementService {
paramCount: number;
} | null> {
try {
// 🔧 날짜 범위 문자열 "YYYY-MM-DD|YYYY-MM-DD" 체크 (최우선!)
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
if (typeof value === "string" && value.includes("|")) {
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
// 날짜 타입이면 날짜 범위로 처리
if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) {
return this.buildDateRangeCondition(columnName, value, paramIndex);
}
// 그 외 타입이면 다중선택(IN 조건)으로 처리
const multiValues = value.split("|").filter((v: string) => v.trim() !== "");
if (multiValues.length > 0) {
const placeholders = multiValues.map((_: string, idx: number) => `$${paramIndex + idx}`).join(", ");
logger.info(`🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})`);
return {
whereClause: `${columnName}::text IN (${placeholders})`,
values: multiValues,
paramCount: multiValues.length,
};
}
}
// 🔧 날짜 범위 객체 {from, to} 체크

View File

@ -3,7 +3,7 @@
import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Settings, Filter, Layers, X } from "lucide-react";
import { Settings, Filter, Layers, X, Check, ChevronsUpDown } from "lucide-react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel";
@ -13,6 +13,9 @@ import { TableFilter } from "@/types/table-options";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ModernDatePicker } from "@/components/screen/filters/ModernDatePicker";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
interface PresetFilter {
id: string;
@ -20,6 +23,7 @@ interface PresetFilter {
columnLabel: string;
filterType: "text" | "number" | "date" | "select";
width?: number;
multiSelect?: boolean; // 다중선택 여부 (select 타입에서만 사용)
}
interface TableSearchWidgetProps {
@ -280,6 +284,11 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
}
}
// 다중선택 배열을 처리 (파이프로 연결된 문자열로 변환)
if (filter.filterType === "select" && Array.isArray(filterValue)) {
filterValue = filterValue.join("|");
}
return {
...filter,
value: filterValue || "",
@ -289,6 +298,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 빈 값 체크
if (!f.value) return false;
if (typeof f.value === "string" && f.value === "") return false;
if (Array.isArray(f.value) && f.value.length === 0) return false;
return true;
});
@ -343,12 +353,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
case "select": {
let options = selectOptions[filter.columnName] || [];
// 현재 선택된 값이 옵션 목록에 없으면 추가 (데이터 없을 때도 선택값 유지)
if (value && !options.find((opt) => opt.value === value)) {
const savedLabel = selectedLabels[filter.columnName] || value;
options = [{ value, label: savedLabel }, ...options];
}
// 중복 제거 (value 기준)
const uniqueOptions = options.reduce(
(acc, option) => {
@ -360,39 +364,86 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
[] as Array<{ value: string; label: string }>,
);
// 항상 다중선택 모드
const selectedValues: string[] = Array.isArray(value) ? value : (value ? [value] : []);
// 선택된 값들의 라벨 표시
const getDisplayText = () => {
if (selectedValues.length === 0) return column?.columnLabel || "선택";
if (selectedValues.length === 1) {
const opt = uniqueOptions.find(o => o.value === selectedValues[0]);
return opt?.label || selectedValues[0];
}
return `${selectedValues.length}개 선택됨`;
};
const handleMultiSelectChange = (optionValue: string, checked: boolean) => {
let newValues: string[];
if (checked) {
newValues = [...selectedValues, optionValue];
} else {
newValues = selectedValues.filter(v => v !== optionValue);
}
handleFilterChange(filter.columnName, newValues.length > 0 ? newValues : "");
};
return (
<Select
value={value}
onValueChange={(val) => {
// 선택한 값의 라벨 저장
const selectedOption = uniqueOptions.find((opt) => opt.value === val);
if (selectedOption) {
setSelectedLabels((prev) => ({
...prev,
[filter.columnName]: selectedOption.label,
}));
}
handleFilterChange(filter.columnName, val);
}}
>
<SelectTrigger
className="h-9 min-h-9 text-xs focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"h-9 min-h-9 justify-between text-xs font-normal focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 sm:text-sm",
selectedValues.length === 0 && "text-muted-foreground"
)}
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
>
<span className="truncate">{getDisplayText()}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: `${width}px` }}
align="start"
>
<SelectValue placeholder={column?.columnLabel || "선택"} />
</SelectTrigger>
<SelectContent>
{uniqueOptions.length === 0 ? (
<div className="text-muted-foreground px-2 py-1.5 text-xs"> </div>
) : (
uniqueOptions.map((option, index) => (
<SelectItem key={`${filter.columnName}-${option.value}-${index}`} value={option.value}>
{option.label}
</SelectItem>
))
<div className="max-h-60 overflow-auto">
{uniqueOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs"> </div>
) : (
<div className="p-1">
{uniqueOptions.map((option, index) => (
<div
key={`${filter.columnName}-multi-${option.value}-${index}`}
className="flex items-center space-x-2 rounded-sm px-2 py-1.5 hover:bg-accent cursor-pointer"
onClick={() => handleMultiSelectChange(option.value, !selectedValues.includes(option.value))}
>
<Checkbox
checked={selectedValues.includes(option.value)}
onCheckedChange={(checked) => handleMultiSelectChange(option.value, checked as boolean)}
onClick={(e) => e.stopPropagation()}
/>
<span className="text-xs sm:text-sm">{option.label}</span>
</div>
))}
</div>
)}
</div>
{selectedValues.length > 0 && (
<div className="border-t p-1">
<Button
variant="ghost"
size="sm"
className="w-full h-7 text-xs"
onClick={() => handleFilterChange(filter.columnName, "")}
>
</Button>
</div>
)}
</SelectContent>
</Select>
</PopoverContent>
</Popover>
);
}

View File

@ -29,6 +29,7 @@ interface PresetFilter {
columnLabel: string;
filterType: "text" | "number" | "date" | "select";
width?: number;
multiSelect?: boolean; // 다중선택 여부 (select 타입에서만 사용)
}
export function TableSearchWidgetConfigPanel({