feat(pop-button): 제어 실행 기능 추가 + 연결 데이터 UX 개선 + 필터 UI 개선
POP 버튼 컴포넌트에서 백엔드 제어관리(node_flows)를 직접 실행할 수 있도록 "제어 실행" 작업 유형을 추가하고, 데이터 수정 시 연결된 컴포넌트 기반으로 필드를 선택하는 UX로 개선한다. [제어 실행 (custom-event 확장)] - ButtonTask에 flowId/flowName 필드 추가 - ControlFlowTaskForm: Combobox(Popover+Command)로 검색/선택 UI - executePopAction: flowId 기반 POST /dataflow/node-flows/:flowId/execute - 기존 eventName 발행 메커니즘은 폴백으로 유지 [연결 데이터 UX 개선] - extractCardFields -> extractConnectedFields 리팩토링 (connections 기반 연결 컴포넌트에서만 필드 추출) - pop-card-list/pop-field/pop-search 타입별 필드 추출 로직 - 시스템 필드(__cart_quantity 등)에 한글 라벨 부여 - UI 라벨: "화면 데이터" -> "연결된 데이터" [pop-card-list 필터 UI] - 필터 조건 레이아웃을 가로 -> 세로 스택으로 변경 - 조건 번호 표시 + 입력 필드 높이 확대 [버그 수정] - apiClient baseURL 이중 /api 경로 수정 - 응답 필드명 camelCase 통일
This commit is contained in:
parent
20ad1d6829
commit
62e11127a7
|
|
@ -322,7 +322,9 @@ export async function executeTaskList(
|
|||
}
|
||||
|
||||
case "custom-event":
|
||||
if (task.eventName) {
|
||||
if (task.flowId) {
|
||||
await apiClient.post(`/dataflow/node-flows/${task.flowId}/execute`, {});
|
||||
} else if (task.eventName) {
|
||||
publish(task.eventName, task.eventPayload ?? {});
|
||||
}
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,19 @@ import {
|
|||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
||||
import { usePopAction } from "@/hooks/pop/usePopAction";
|
||||
import { executeTaskList, type CollectedPayload } from "@/hooks/pop/executePopAction";
|
||||
|
|
@ -51,6 +64,7 @@ import {
|
|||
PackageCheck,
|
||||
ChevronRight,
|
||||
GripVertical,
|
||||
ChevronsUpDown,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -227,9 +241,11 @@ export interface ButtonTask {
|
|||
apiEndpoint?: string;
|
||||
apiMethod?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
|
||||
// custom-event
|
||||
// custom-event (제어 실행)
|
||||
eventName?: string;
|
||||
eventPayload?: Record<string, unknown>;
|
||||
flowId?: number;
|
||||
flowName?: string;
|
||||
}
|
||||
|
||||
/** 빠른 시작 템플릿 */
|
||||
|
|
@ -345,7 +361,7 @@ export const TASK_TYPE_LABELS: Record<ButtonTaskType, string> = {
|
|||
"close-modal": "모달 닫기",
|
||||
"refresh": "새로고침",
|
||||
"api-call": "API 호출",
|
||||
"custom-event": "커스텀 이벤트",
|
||||
"custom-event": "제어 실행",
|
||||
};
|
||||
|
||||
/** 빠른 시작 템플릿별 기본 작업 목록 + 외형 */
|
||||
|
|
@ -1267,39 +1283,80 @@ interface PopButtonConfigPanelProps {
|
|||
componentId?: string;
|
||||
}
|
||||
|
||||
/** 화면 내 카드 컴포넌트에서 사용 가능한 필드 목록 추출 */
|
||||
function extractCardFields(
|
||||
/** 연결된 컴포넌트에서 사용 가능한 필드 목록 추출 (연결 기반) */
|
||||
function extractConnectedFields(
|
||||
componentId?: string,
|
||||
connections?: PopButtonConfigPanelProps["connections"],
|
||||
allComponents?: PopButtonConfigPanelProps["allComponents"],
|
||||
): { value: string; label: string; source: string }[] {
|
||||
if (!allComponents) return [];
|
||||
if (!componentId || !connections || !allComponents) return [];
|
||||
|
||||
const targetIds = connections
|
||||
.filter((c) => c.sourceComponent === componentId || c.targetComponent === componentId)
|
||||
.map((c) => (c.sourceComponent === componentId ? c.targetComponent : c.sourceComponent));
|
||||
const uniqueIds = [...new Set(targetIds)];
|
||||
if (uniqueIds.length === 0) return [];
|
||||
|
||||
const fields: { value: string; label: string; source: string }[] = [];
|
||||
|
||||
for (const comp of allComponents) {
|
||||
if (comp.type !== "pop-card-list" || !comp.config) continue;
|
||||
const tpl = (comp.config as Record<string, unknown>).cardTemplate as
|
||||
| { header?: Record<string, unknown>; body?: { fields?: { id?: string; label?: string; valueType?: string; columnName?: string }[] } }
|
||||
| undefined;
|
||||
if (!tpl) continue;
|
||||
for (const tid of uniqueIds) {
|
||||
const comp = allComponents.find((c) => c.id === tid);
|
||||
if (!comp?.config) continue;
|
||||
const cfg = comp.config as Record<string, unknown>;
|
||||
const compLabel = (comp as Record<string, unknown>).label as string || comp.type || tid;
|
||||
|
||||
if (tpl.header?.codeField) {
|
||||
fields.push({ value: String(tpl.header.codeField), label: String(tpl.header.codeField), source: "헤더 코드" });
|
||||
if (comp.type === "pop-card-list") {
|
||||
const tpl = cfg.cardTemplate as
|
||||
| { header?: Record<string, unknown>; body?: { fields?: { id?: string; label?: string; valueType?: string; columnName?: string }[] } }
|
||||
| undefined;
|
||||
if (tpl) {
|
||||
if (tpl.header?.codeField) {
|
||||
fields.push({ value: String(tpl.header.codeField), label: String(tpl.header.codeField), source: compLabel });
|
||||
}
|
||||
if (tpl.header?.titleField) {
|
||||
fields.push({ value: String(tpl.header.titleField), label: String(tpl.header.titleField), source: compLabel });
|
||||
}
|
||||
for (const f of tpl.body?.fields ?? []) {
|
||||
if (f.valueType === "column" && f.columnName) {
|
||||
fields.push({ value: f.columnName, label: f.label || f.columnName, source: compLabel });
|
||||
} else if (f.valueType === "formula" && f.label) {
|
||||
fields.push({ value: `__formula_${f.id || f.label}`, label: f.label, source: `${compLabel} (수식)` });
|
||||
}
|
||||
}
|
||||
}
|
||||
fields.push({ value: "__cart_quantity", label: "사용자 입력 수량", source: `${compLabel} (장바구니)` });
|
||||
fields.push({ value: "__cart_row_key", label: "선택한 카드의 원본 키", source: `${compLabel} (장바구니)` });
|
||||
fields.push({ value: "__cart_id", label: "장바구니 항목 ID", source: `${compLabel} (장바구니)` });
|
||||
}
|
||||
if (tpl.header?.titleField) {
|
||||
fields.push({ value: String(tpl.header.titleField), label: String(tpl.header.titleField), source: "헤더 제목" });
|
||||
}
|
||||
for (const f of tpl.body?.fields ?? []) {
|
||||
if (f.valueType === "column" && f.columnName) {
|
||||
fields.push({ value: f.columnName, label: f.label || f.columnName, source: "본문" });
|
||||
} else if (f.valueType === "formula" && f.label) {
|
||||
const formulaKey = `__formula_${f.id || f.label}`;
|
||||
fields.push({ value: formulaKey, label: f.label, source: "수식" });
|
||||
|
||||
if (comp.type === "pop-field") {
|
||||
const sections = cfg.sections as Array<{
|
||||
fields?: Array<{ id: string; fieldName?: string; labelText?: string }>;
|
||||
}> | undefined;
|
||||
if (Array.isArray(sections)) {
|
||||
for (const section of sections) {
|
||||
for (const f of section.fields ?? []) {
|
||||
if (f.fieldName) {
|
||||
fields.push({ value: f.fieldName, label: f.labelText || f.fieldName, source: compLabel });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 시스템 필드 추가
|
||||
fields.push({ value: "__cart_quantity", label: "수량 (장바구니)", source: "시스템" });
|
||||
fields.push({ value: "__cart_row_key", label: "원본 키", source: "시스템" });
|
||||
fields.push({ value: "__cart_id", label: "카드 항목 ID", source: "시스템" });
|
||||
if (comp.type === "pop-search") {
|
||||
const filterCols = cfg.filterColumns as string[] | undefined;
|
||||
const modalCfg = cfg.modalConfig as { valueField?: string } | undefined;
|
||||
if (Array.isArray(filterCols) && filterCols.length > 0) {
|
||||
for (const col of filterCols) {
|
||||
fields.push({ value: col, label: col, source: compLabel });
|
||||
}
|
||||
} else if (modalCfg?.valueField) {
|
||||
fields.push({ value: modalCfg.valueField, label: modalCfg.valueField, source: compLabel });
|
||||
} else if (cfg.fieldName && typeof cfg.fieldName === "string") {
|
||||
fields.push({ value: cfg.fieldName, label: (cfg.placeholder as string) || cfg.fieldName, source: compLabel });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
|
|
@ -1309,9 +1366,14 @@ export function PopButtonConfigPanel({
|
|||
config,
|
||||
onUpdate,
|
||||
allComponents,
|
||||
connections,
|
||||
componentId,
|
||||
}: PopButtonConfigPanelProps) {
|
||||
const v2 = useMemo(() => migrateButtonConfig(config), [config]);
|
||||
const cardFields = useMemo(() => extractCardFields(allComponents), [allComponents]);
|
||||
const cardFields = useMemo(
|
||||
() => extractConnectedFields(componentId, connections, allComponents),
|
||||
[componentId, connections, allComponents],
|
||||
);
|
||||
|
||||
const updateV2 = useCallback(
|
||||
(partial: Partial<PopButtonConfigV2>) => {
|
||||
|
|
@ -1560,7 +1622,7 @@ function buildTaskSummary(task: ButtonTask): string {
|
|||
case "api-call":
|
||||
return task.apiEndpoint || "";
|
||||
case "custom-event":
|
||||
return task.eventName || "";
|
||||
return task.flowName || task.eventName || "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
|
@ -1829,15 +1891,10 @@ function TaskDetailForm({
|
|||
|
||||
case "custom-event":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">이벤트명</Label>
|
||||
<Input
|
||||
value={task.eventName || ""}
|
||||
onChange={(e) => onUpdate({ eventName: e.target.value })}
|
||||
placeholder="예: data-saved, item-selected"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<ControlFlowTaskForm
|
||||
task={task}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
);
|
||||
|
||||
case "refresh":
|
||||
|
|
@ -1897,7 +1954,7 @@ function buildUpdateSummaryText(task: ButtonTask): string | null {
|
|||
}
|
||||
|
||||
const val = task.valueSource === "linked"
|
||||
? `화면 데이터(${task.sourceField || "?"})`
|
||||
? `연결 데이터(${task.sourceField || "?"})`
|
||||
: `"${task.fixedValue || "?"}"`;
|
||||
return `[${task.targetTable}].${task.targetColumn} ${opLabel} ${val}`;
|
||||
}
|
||||
|
|
@ -2003,7 +2060,7 @@ function DataUpdateTaskForm({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fixed" className="text-xs">직접 입력</SelectItem>
|
||||
<SelectItem value="linked" className="text-xs">화면 데이터에서 가져오기</SelectItem>
|
||||
<SelectItem value="linked" className="text-xs">연결된 데이터 가져오기</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -2022,19 +2079,19 @@ function DataUpdateTaskForm({
|
|||
|
||||
{task.valueSource === "linked" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">어떤 화면 데이터를 사용할까요?</Label>
|
||||
<Label className="text-xs font-medium">어떤 데이터를 사용할까요?</Label>
|
||||
{cardFields.length > 0 ? (
|
||||
<Select
|
||||
value={task.sourceField || ""}
|
||||
onValueChange={(v) => onUpdate({ sourceField: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
<SelectValue placeholder="데이터 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cardFields.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value} className="text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span>{f.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{f.source}</span>
|
||||
</div>
|
||||
|
|
@ -2052,8 +2109,8 @@ function DataUpdateTaskForm({
|
|||
)}
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{cardFields.length > 0
|
||||
? "카드에 표시되는 데이터 중 하나를 선택합니다"
|
||||
: "카드 컴포넌트가 없으면 직접 입력해주세요"}
|
||||
? "연결된 컴포넌트의 데이터 중 하나를 선택합니다"
|
||||
: "연결된 컴포넌트가 없습니다. 연결 탭에서 먼저 연결해주세요"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -2924,6 +2981,107 @@ function SingleRuleEditor({
|
|||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 제어 실행 작업 폼 (custom-event -> 제어 플로우)
|
||||
// ========================================
|
||||
|
||||
function ControlFlowTaskForm({
|
||||
task,
|
||||
onUpdate,
|
||||
}: {
|
||||
task: ButtonTask;
|
||||
onUpdate: (partial: Partial<ButtonTask>) => void;
|
||||
}) {
|
||||
const [flows, setFlows] = useState<{ flowId: number; flowName: string; flowDescription?: string }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
apiClient
|
||||
.get("/dataflow/node-flows")
|
||||
.then((res) => {
|
||||
const data = res.data?.data ?? res.data ?? [];
|
||||
if (Array.isArray(data)) {
|
||||
setFlows(data);
|
||||
}
|
||||
})
|
||||
.catch(() => setFlows([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const selectedFlow = flows.find((f) => f.flowId === task.flowId);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">제어 플로우 선택</Label>
|
||||
{loading ? (
|
||||
<p className="text-[11px] text-muted-foreground">목록 불러오는 중...</p>
|
||||
) : (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{selectedFlow ? selectedFlow.flowName : "플로우를 선택하세요"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="플로우 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="p-2 text-center text-xs">
|
||||
검색 결과 없음
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{flows.map((f) => (
|
||||
<CommandItem
|
||||
key={f.flowId}
|
||||
value={f.flowName}
|
||||
onSelect={() => {
|
||||
onUpdate({
|
||||
flowId: f.flowId,
|
||||
flowName: f.flowName,
|
||||
eventName: `__node_flow_${f.flowId}`,
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
task.flowId === f.flowId ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{f.flowName}</span>
|
||||
{f.flowDescription && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{f.flowDescription}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 레지스트리 등록
|
||||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-button",
|
||||
|
|
|
|||
|
|
@ -2039,16 +2039,29 @@ function FilterSettingsSection({
|
|||
{filters.map((filter, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-1 rounded-md border bg-card p-1.5"
|
||||
className="space-y-1.5 rounded-md border bg-card p-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
조건 {index + 1}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive"
|
||||
onClick={() => deleteFilter(index)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
value={filter.column || ""}
|
||||
onValueChange={(val) =>
|
||||
updateFilter(index, { ...filter, column: val })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs flex-1">
|
||||
<SelectValue placeholder="컬럼" />
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
|
|
@ -2058,45 +2071,36 @@ function FilterSettingsSection({
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(val) =>
|
||||
updateFilter(index, {
|
||||
...filter,
|
||||
operator: val as FilterOperator,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-16 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{operators.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
value={filter.value}
|
||||
onChange={(e) =>
|
||||
updateFilter(index, { ...filter, value: e.target.value })
|
||||
}
|
||||
placeholder="값"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-destructive"
|
||||
onClick={() => deleteFilter(index)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(val) =>
|
||||
updateFilter(index, {
|
||||
...filter,
|
||||
operator: val as FilterOperator,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20 shrink-0 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{operators.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={filter.value}
|
||||
onChange={(e) =>
|
||||
updateFilter(index, { ...filter, value: e.target.value })
|
||||
}
|
||||
placeholder="값 입력"
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -2663,46 +2667,51 @@ function FilterCriteriaSection({
|
|||
) : (
|
||||
<div className="space-y-2">
|
||||
{filters.map((filter, index) => (
|
||||
<div key={index} className="flex items-center gap-1 rounded-md border bg-card p-1.5">
|
||||
<div className="flex-1">
|
||||
<GroupedColumnSelect
|
||||
columnGroups={columnGroups}
|
||||
value={filter.column || undefined}
|
||||
onValueChange={(val) => updateFilter(index, { ...filter, column: val || "" })}
|
||||
placeholder="컬럼 선택"
|
||||
<div key={index} className="space-y-1.5 rounded-md border bg-card p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
조건 {index + 1}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive"
|
||||
onClick={() => deleteFilter(index)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<GroupedColumnSelect
|
||||
columnGroups={columnGroups}
|
||||
value={filter.column || undefined}
|
||||
onValueChange={(val) => updateFilter(index, { ...filter, column: val || "" })}
|
||||
placeholder="컬럼 선택"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(val) =>
|
||||
updateFilter(index, { ...filter, operator: val as FilterOperator })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20 shrink-0 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={filter.value}
|
||||
onChange={(e) => updateFilter(index, { ...filter, value: e.target.value })}
|
||||
placeholder="값 입력"
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(val) =>
|
||||
updateFilter(index, { ...filter, operator: val as FilterOperator })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-16 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={filter.value}
|
||||
onChange={(e) => updateFilter(index, { ...filter, value: e.target.value })}
|
||||
placeholder="값"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-destructive"
|
||||
onClick={() => deleteFilter(index)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue