diff --git a/frontend/components/pop/designer/panels/ConnectionEditor.tsx b/frontend/components/pop/designer/panels/ConnectionEditor.tsx
index 52c8102f..747b8f70 100644
--- a/frontend/components/pop/designer/panels/ConnectionEditor.tsx
+++ b/frontend/components/pop/designer/panels/ConnectionEditor.tsx
@@ -1,11 +1,9 @@
"use client";
import React from "react";
-import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react";
+import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
-import { Input } from "@/components/ui/input";
-import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
@@ -19,9 +17,7 @@ import {
} from "../types/pop-layout";
import {
PopComponentRegistry,
- type ComponentConnectionMeta,
} from "@/lib/registry/PopComponentRegistry";
-import { getTableColumns } from "@/lib/api/tableManagement";
// ========================================
// Props
@@ -36,15 +32,6 @@ interface ConnectionEditorProps {
onRemoveConnection?: (connectionId: string) => void;
}
-// ========================================
-// 소스 컴포넌트에 filter 타입 sendable이 있는지 판단
-// ========================================
-
-function hasFilterSendable(meta: ComponentConnectionMeta | undefined): boolean {
- if (!meta?.sendable) return false;
- return meta.sendable.some((s) => s.category === "filter" || s.type === "filter_value");
-}
-
// ========================================
// ConnectionEditor
// ========================================
@@ -84,17 +71,13 @@ export default function ConnectionEditor({
);
}
- const isFilterSource = hasFilterSendable(meta);
-
return (
{hasSendable && (
;
- const cols: string[] = [];
-
- if (Array.isArray(cfg.listColumns)) {
- (cfg.listColumns as Array<{ columnName?: string }>).forEach((c) => {
- if (c.columnName && !cols.includes(c.columnName)) cols.push(c.columnName);
- });
- }
-
- if (Array.isArray(cfg.selectedColumns)) {
- (cfg.selectedColumns as string[]).forEach((c) => {
- if (!cols.includes(c)) cols.push(c);
- });
- }
-
- return cols;
-}
-
-function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
- if (!comp?.config) return "";
- const cfg = comp.config as Record;
- const ds = cfg.dataSource as { tableName?: string } | undefined;
- return ds?.tableName || "";
-}
-
// ========================================
// 보내기 섹션
// ========================================
interface SendSectionProps {
component: PopComponentDefinitionV5;
- meta: ComponentConnectionMeta;
allComponents: PopComponentDefinitionV5[];
outgoing: PopDataConnection[];
- isFilterSource: boolean;
onAddConnection?: (conn: Omit) => void;
onUpdateConnection?: (connectionId: string, conn: Omit) => void;
onRemoveConnection?: (connectionId: string) => void;
@@ -160,10 +110,8 @@ interface SendSectionProps {
function SendSection({
component,
- meta,
allComponents,
outgoing,
- isFilterSource,
onAddConnection,
onUpdateConnection,
onRemoveConnection,
@@ -180,32 +128,17 @@ function SendSection({
{outgoing.map((conn) => (
{editingId === conn.id ? (
- isFilterSource ? (
-
{
- onUpdateConnection?.(conn.id, data);
- setEditingId(null);
- }}
- onCancel={() => setEditingId(null)}
- submitLabel="수정"
- />
- ) : (
- {
- onUpdateConnection?.(conn.id, data);
- setEditingId(null);
- }}
- onCancel={() => setEditingId(null)}
- submitLabel="수정"
- />
- )
+ {
+ onUpdateConnection?.(conn.id, data);
+ setEditingId(null);
+ }}
+ onCancel={() => setEditingId(null)}
+ submitLabel="수정"
+ />
) : (
@@ -230,22 +163,12 @@ function SendSection({
))}
- {isFilterSource ? (
- onAddConnection?.(data)}
- submitLabel="연결 추가"
- />
- ) : (
- onAddConnection?.(data)}
- submitLabel="연결 추가"
- />
- )}
+ onAddConnection?.(data)}
+ submitLabel="연결 추가"
+ />
);
}
@@ -350,328 +273,6 @@ function SimpleConnectionForm({
);
}
-// ========================================
-// 필터 연결 폼 (검색 컴포넌트용: 기존 UI 유지)
-// ========================================
-
-interface FilterConnectionFormProps {
- component: PopComponentDefinitionV5;
- meta: ComponentConnectionMeta;
- allComponents: PopComponentDefinitionV5[];
- initial?: PopDataConnection;
- onSubmit: (data: Omit) => void;
- onCancel?: () => void;
- submitLabel: string;
-}
-
-function FilterConnectionForm({
- component,
- meta,
- allComponents,
- initial,
- onSubmit,
- onCancel,
- submitLabel,
-}: FilterConnectionFormProps) {
- const [selectedOutput, setSelectedOutput] = React.useState(
- initial?.sourceOutput || meta.sendable[0]?.key || ""
- );
- const [selectedTargetId, setSelectedTargetId] = React.useState(
- initial?.targetComponent || ""
- );
- const [selectedTargetInput, setSelectedTargetInput] = React.useState(
- initial?.targetInput || ""
- );
- const [filterColumns, setFilterColumns] = React.useState(
- initial?.filterConfig?.targetColumns ||
- (initial?.filterConfig?.targetColumn ? [initial.filterConfig.targetColumn] : [])
- );
- const [filterMode, setFilterMode] = React.useState<
- "equals" | "contains" | "starts_with" | "range"
- >(initial?.filterConfig?.filterMode || "contains");
-
- const targetCandidates = allComponents.filter((c) => {
- if (c.id === component.id) return false;
- const reg = PopComponentRegistry.getComponent(c.type);
- return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
- });
-
- const targetComp = selectedTargetId
- ? allComponents.find((c) => c.id === selectedTargetId)
- : null;
-
- const targetMeta = targetComp
- ? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
- : null;
-
- React.useEffect(() => {
- if (!selectedOutput || !targetMeta?.receivable?.length) return;
- if (selectedTargetInput) return;
-
- const receivables = targetMeta.receivable;
- const exactMatch = receivables.find((r) => r.key === selectedOutput);
- if (exactMatch) {
- setSelectedTargetInput(exactMatch.key);
- return;
- }
- if (receivables.length === 1) {
- setSelectedTargetInput(receivables[0].key);
- }
- }, [selectedOutput, targetMeta, selectedTargetInput]);
-
- const displayColumns = React.useMemo(
- () => extractDisplayColumns(targetComp || undefined),
- [targetComp]
- );
-
- const tableName = React.useMemo(
- () => extractTableName(targetComp || undefined),
- [targetComp]
- );
- const [allDbColumns, setAllDbColumns] = React.useState([]);
- const [dbColumnsLoading, setDbColumnsLoading] = React.useState(false);
-
- React.useEffect(() => {
- if (!tableName) {
- setAllDbColumns([]);
- return;
- }
- let cancelled = false;
- setDbColumnsLoading(true);
- getTableColumns(tableName).then((res) => {
- if (cancelled) return;
- if (res.success && res.data?.columns) {
- setAllDbColumns(res.data.columns.map((c) => c.columnName));
- } else {
- setAllDbColumns([]);
- }
- setDbColumnsLoading(false);
- });
- return () => { cancelled = true; };
- }, [tableName]);
-
- const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]);
- const dataOnlyColumns = React.useMemo(
- () => allDbColumns.filter((c) => !displaySet.has(c)),
- [allDbColumns, displaySet]
- );
- const hasAnyColumns = displayColumns.length > 0 || dataOnlyColumns.length > 0;
-
- const toggleColumn = (col: string) => {
- setFilterColumns((prev) =>
- prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col]
- );
- };
-
- const handleSubmit = () => {
- if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return;
-
- const isEvent = isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput);
-
- onSubmit({
- sourceComponent: component.id,
- sourceField: "",
- sourceOutput: selectedOutput,
- targetComponent: selectedTargetId,
- targetField: "",
- targetInput: selectedTargetInput,
- filterConfig:
- !isEvent && filterColumns.length > 0
- ? {
- targetColumn: filterColumns[0],
- targetColumns: filterColumns,
- filterMode,
- }
- : undefined,
- label: buildConnectionLabel(
- component,
- selectedOutput,
- allComponents.find((c) => c.id === selectedTargetId),
- selectedTargetInput,
- filterColumns
- ),
- });
-
- if (!initial) {
- setSelectedTargetId("");
- setSelectedTargetInput("");
- setFilterColumns([]);
- }
- };
-
- return (
-
- {onCancel && (
-
- )}
- {!onCancel && (
-
새 연결 추가
- )}
-
-
- 보내는 값
-
-
-
-
- 받는 컴포넌트
-
-
-
- {targetMeta && (
-
- 받는 방식
-
-
- )}
-
- {selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (
-
-
필터할 컬럼
-
- {dbColumnsLoading ? (
-
-
- 컬럼 조회 중...
-
- ) : hasAnyColumns ? (
-
- {displayColumns.length > 0 && (
-
-
화면 표시 컬럼
- {displayColumns.map((col) => (
-
- toggleColumn(col)}
- />
-
-
- ))}
-
- )}
-
- {dataOnlyColumns.length > 0 && (
-
- {displayColumns.length > 0 && (
-
- )}
-
데이터 전용 컬럼
- {dataOnlyColumns.map((col) => (
-
- toggleColumn(col)}
- />
-
-
- ))}
-
- )}
-
- ) : (
-
setFilterColumns(e.target.value ? [e.target.value] : [])}
- placeholder="컬럼명 입력"
- className="h-7 text-xs"
- />
- )}
-
- {filterColumns.length > 0 && (
-
- {filterColumns.length}개 컬럼 중 하나라도 일치하면 표시
-
- )}
-
-
-
필터 방식
-
-
-
- )}
-
-
-
- );
-}
-
// ========================================
// 받기 섹션 (읽기 전용: 연결된 소스만 표시)
// ========================================
@@ -722,32 +323,3 @@ function ReceiveSection({
);
}
-// ========================================
-// 유틸
-// ========================================
-
-function isEventTypeConnection(
- sourceMeta: ComponentConnectionMeta | undefined,
- outputKey: string,
- targetMeta: ComponentConnectionMeta | null | undefined,
- inputKey: string,
-): boolean {
- const sourceItem = sourceMeta?.sendable?.find((s) => s.key === outputKey);
- const targetItem = targetMeta?.receivable?.find((r) => r.key === inputKey);
- return sourceItem?.type === "event" || targetItem?.type === "event";
-}
-
-function buildConnectionLabel(
- source: PopComponentDefinitionV5,
- _outputKey: string,
- target: PopComponentDefinitionV5 | undefined,
- _inputKey: string,
- columns?: string[]
-): string {
- const srcLabel = source.label || source.id;
- const tgtLabel = target?.label || target?.id || "?";
- const colInfo = columns && columns.length > 0
- ? ` [${columns.join(", ")}]`
- : "";
- return `${srcLabel} → ${tgtLabel}${colInfo}`;
-}
diff --git a/frontend/hooks/pop/useConnectionResolver.ts b/frontend/hooks/pop/useConnectionResolver.ts
index 14bd321a..a778f35f 100644
--- a/frontend/hooks/pop/useConnectionResolver.ts
+++ b/frontend/hooks/pop/useConnectionResolver.ts
@@ -20,7 +20,6 @@ import { usePopEvent } from "./usePopEvent";
import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout";
import {
PopComponentRegistry,
- type ConnectionMetaItem,
} from "@/lib/registry/PopComponentRegistry";
interface UseConnectionResolverOptions {
@@ -29,14 +28,21 @@ interface UseConnectionResolverOptions {
componentTypes?: Map;
}
+interface AutoMatchPair {
+ sourceKey: string;
+ targetKey: string;
+ isFilter: boolean;
+}
+
/**
- * 소스/타겟의 connectionMeta에서 자동 매칭 가능한 이벤트 쌍을 찾는다.
- * 규칙: category="event"이고 key가 동일한 쌍
+ * 소스/타겟의 connectionMeta에서 자동 매칭 가능한 쌍을 찾는다.
+ * 규칙 1: category="event"이고 key가 동일한 쌍 (이벤트 매칭)
+ * 규칙 2: 소스 type="filter_value" + 타겟 type="filter_value" (필터 매칭)
*/
function getAutoMatchPairs(
sourceType: string,
targetType: string
-): { sourceKey: string; targetKey: string }[] {
+): AutoMatchPair[] {
const sourceDef = PopComponentRegistry.getComponent(sourceType);
const targetDef = PopComponentRegistry.getComponent(targetType);
@@ -44,14 +50,15 @@ function getAutoMatchPairs(
return [];
}
- const pairs: { sourceKey: string; targetKey: string }[] = [];
+ const pairs: AutoMatchPair[] = [];
for (const s of sourceDef.connectionMeta.sendable) {
- if (s.category !== "event") continue;
for (const r of targetDef.connectionMeta.receivable) {
- if (r.category !== "event") continue;
- if (s.key === r.key) {
- pairs.push({ sourceKey: s.key, targetKey: r.key });
+ if (s.category === "event" && r.category === "event" && s.key === r.key) {
+ pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false });
+ }
+ if (s.type === "filter_value" && r.type === "filter_value") {
+ pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: true });
}
}
}
@@ -93,10 +100,24 @@ export function useConnectionResolver({
const targetEvent = `__comp_input__${conn.targetComponent}__${pair.targetKey}`;
const unsub = subscribe(sourceEvent, (payload: unknown) => {
- publish(targetEvent, {
- value: payload,
- _connectionId: conn.id,
- });
+ if (pair.isFilter) {
+ const data = payload as Record | null;
+ const fieldName = data?.fieldName as string | undefined;
+ const filterColumns = data?.filterColumns as string[] | undefined;
+ const filterMode = (data?.filterMode as string) || "contains";
+ publish(targetEvent, {
+ value: payload,
+ filterConfig: fieldName
+ ? { targetColumn: fieldName, targetColumns: filterColumns?.length ? filterColumns : [fieldName], filterMode }
+ : conn.filterConfig,
+ _connectionId: conn.id,
+ });
+ } else {
+ publish(targetEvent, {
+ value: payload,
+ _connectionId: conn.id,
+ });
+ }
});
unsubscribers.push(unsub);
}
@@ -121,13 +142,22 @@ export function useConnectionResolver({
const unsub = subscribe(sourceEvent, (payload: unknown) => {
const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`;
- const enrichedPayload = {
- value: payload,
- filterConfig: conn.filterConfig,
- _connectionId: conn.id,
- };
+ let resolvedFilterConfig = conn.filterConfig;
+ if (!resolvedFilterConfig) {
+ const data = payload as Record | null;
+ const fieldName = data?.fieldName as string | undefined;
+ const filterColumns = data?.filterColumns as string[] | undefined;
+ if (fieldName) {
+ const filterMode = (data?.filterMode as string) || "contains";
+ resolvedFilterConfig = { targetColumn: fieldName, targetColumns: filterColumns?.length ? filterColumns : [fieldName], filterMode: filterMode as "equals" | "contains" | "starts_with" | "range" };
+ }
+ }
- publish(targetEvent, enrichedPayload);
+ publish(targetEvent, {
+ value: payload,
+ filterConfig: resolvedFilterConfig,
+ _connectionId: conn.id,
+ });
});
unsubscribers.push(unsub);
}
diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx
index 380cc103..6b20c6a7 100644
--- a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx
+++ b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx
@@ -62,9 +62,11 @@ export function PopSearchComponent({
const [modalDisplayText, setModalDisplayText] = useState("");
const [simpleModalOpen, setSimpleModalOpen] = useState(false);
- const fieldKey = config.fieldName || componentId || "search";
const normalizedType = normalizeInputType(config.inputType as string);
const isModalType = normalizedType === "modal";
+ const fieldKey = isModalType
+ ? (config.modalConfig?.valueField || config.fieldName || componentId || "search")
+ : (config.fieldName || componentId || "search");
const emitFilterChanged = useCallback(
(newValue: unknown) => {
@@ -72,15 +74,18 @@ export function PopSearchComponent({
setSharedData(`search_${fieldKey}`, newValue);
if (componentId) {
+ const filterColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey];
publish(`__comp_output__${componentId}__filter_value`, {
fieldName: fieldKey,
+ filterColumns,
value: newValue,
+ filterMode: config.filterMode || "contains",
});
}
publish("filter_changed", { [fieldKey]: newValue });
},
- [fieldKey, publish, setSharedData, componentId]
+ [fieldKey, publish, setSharedData, componentId, config.filterMode, config.filterColumns]
);
useEffect(() => {
diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx
index 96507984..2f9949eb 100644
--- a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx
+++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useMemo } from "react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -13,7 +13,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown } from "lucide-react";
+import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown, AlertTriangle } from "lucide-react";
import {
Popover,
PopoverContent,
@@ -30,6 +30,7 @@ import {
import type {
PopSearchConfig,
SearchInputType,
+ SearchFilterMode,
DatePresetOption,
ModalSelectConfig,
ModalDisplayStyle,
@@ -38,6 +39,7 @@ import type {
} from "./types";
import {
SEARCH_INPUT_TYPE_LABELS,
+ SEARCH_FILTER_MODE_LABELS,
DATE_PRESET_LABELS,
MODAL_DISPLAY_STYLE_LABELS,
MODAL_SEARCH_MODE_LABELS,
@@ -69,9 +71,12 @@ const DEFAULT_CONFIG: PopSearchConfig = {
interface ConfigPanelProps {
config: PopSearchConfig | undefined;
onUpdate: (config: PopSearchConfig) => void;
+ allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
+ connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
+ componentId?: string;
}
-export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
+export function PopSearchConfigPanel({ config, onUpdate, allComponents, connections, componentId }: ConfigPanelProps) {
const [step, setStep] = useState(0);
const rawCfg = { ...DEFAULT_CONFIG, ...(config || {}) };
const cfg: PopSearchConfig = {
@@ -110,7 +115,7 @@ export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
{step === 0 && }
- {step === 1 && }
+ {step === 1 && }
>
)}
+
);
}
@@ -224,16 +233,16 @@ function StepBasicSettings({ cfg, update }: StepProps) {
// STEP 2: 타입별 상세 설정
// ========================================
-function StepDetailSettings({ cfg, update }: StepProps) {
+function StepDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
const normalized = normalizeInputType(cfg.inputType as string);
switch (normalized) {
case "text":
case "number":
- return ;
+ return ;
case "select":
- return ;
+ return ;
case "date-preset":
- return ;
+ return ;
case "modal":
return ;
case "toggle":
@@ -255,11 +264,278 @@ function StepDetailSettings({ cfg, update }: StepProps) {
}
}
+// ========================================
+// 공통: 필터 연결 설정 섹션
+// ========================================
+
+interface FilterConnectionSectionProps {
+ cfg: PopSearchConfig;
+ update: (partial: Partial) => void;
+ showFieldName: boolean;
+ fixedFilterMode?: SearchFilterMode;
+ allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
+ connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
+ componentId?: string;
+}
+
+interface ConnectedComponentInfo {
+ tableNames: string[];
+ displayedColumns: Set;
+}
+
+/**
+ * 연결된 대상 컴포넌트의 tableName과 카드에서 표시 중인 컬럼을 추출한다.
+ */
+function getConnectedComponentInfo(
+ componentId?: string,
+ connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[],
+ allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[],
+): ConnectedComponentInfo {
+ const empty: ConnectedComponentInfo = { tableNames: [], displayedColumns: new Set() };
+ if (!componentId || !connections || !allComponents) return empty;
+
+ const targetIds = connections
+ .filter((c) => c.sourceComponent === componentId)
+ .map((c) => c.targetComponent);
+
+ const tableNames = new Set();
+ const displayedColumns = new Set();
+
+ for (const tid of targetIds) {
+ const comp = allComponents.find((c) => c.id === tid);
+ if (!comp?.config) continue;
+ const compCfg = comp.config as Record;
+
+ const tn = compCfg.dataSource?.tableName;
+ if (tn) tableNames.add(tn);
+
+ // pop-card-list: cardTemplate에서 사용 중인 컬럼 수집
+ const tpl = compCfg.cardTemplate;
+ if (tpl) {
+ if (tpl.header?.codeField) displayedColumns.add(tpl.header.codeField);
+ if (tpl.header?.titleField) displayedColumns.add(tpl.header.titleField);
+ if (tpl.image?.imageColumn) displayedColumns.add(tpl.image.imageColumn);
+ if (Array.isArray(tpl.body?.fields)) {
+ for (const f of tpl.body.fields) {
+ if (f.columnName) displayedColumns.add(f.columnName);
+ }
+ }
+ }
+
+ // pop-string-list: selectedColumns / listColumns
+ if (Array.isArray(compCfg.selectedColumns)) {
+ for (const col of compCfg.selectedColumns) displayedColumns.add(col);
+ }
+ if (Array.isArray(compCfg.listColumns)) {
+ for (const lc of compCfg.listColumns) {
+ if (lc.columnName) displayedColumns.add(lc.columnName);
+ }
+ }
+ }
+
+ return { tableNames: Array.from(tableNames), displayedColumns };
+}
+
+function FilterConnectionSection({ cfg, update, showFieldName, fixedFilterMode, allComponents, connections, componentId }: FilterConnectionSectionProps) {
+ const connInfo = useMemo(
+ () => getConnectedComponentInfo(componentId, connections, allComponents),
+ [componentId, connections, allComponents],
+ );
+
+ const [targetColumns, setTargetColumns] = useState([]);
+ const [columnsLoading, setColumnsLoading] = useState(false);
+
+ const connectedTablesKey = connInfo.tableNames.join(",");
+ useEffect(() => {
+ if (connInfo.tableNames.length === 0) {
+ setTargetColumns([]);
+ return;
+ }
+ let cancelled = false;
+ setColumnsLoading(true);
+
+ Promise.all(connInfo.tableNames.map((t) => getTableColumns(t)))
+ .then((results) => {
+ if (cancelled) return;
+ const allCols: ColumnTypeInfo[] = [];
+ const seen = new Set();
+ for (const res of results) {
+ if (res.success && res.data?.columns) {
+ for (const col of res.data.columns) {
+ if (!seen.has(col.columnName)) {
+ seen.add(col.columnName);
+ allCols.push(col);
+ }
+ }
+ }
+ }
+ setTargetColumns(allCols);
+ })
+ .finally(() => { if (!cancelled) setColumnsLoading(false); });
+
+ return () => { cancelled = true; };
+ }, [connectedTablesKey]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const hasConnection = connInfo.tableNames.length > 0;
+
+ const { displayedCols, otherCols } = useMemo(() => {
+ if (connInfo.displayedColumns.size === 0) {
+ return { displayedCols: [] as ColumnTypeInfo[], otherCols: targetColumns };
+ }
+ const displayed: ColumnTypeInfo[] = [];
+ const others: ColumnTypeInfo[] = [];
+ for (const col of targetColumns) {
+ if (connInfo.displayedColumns.has(col.columnName)) {
+ displayed.push(col);
+ } else {
+ others.push(col);
+ }
+ }
+ return { displayedCols: displayed, otherCols: others };
+ }, [targetColumns, connInfo.displayedColumns]);
+
+ const selectedFilterCols = cfg.filterColumns || (cfg.fieldName ? [cfg.fieldName] : []);
+
+ const toggleFilterColumn = (colName: string) => {
+ const current = new Set(selectedFilterCols);
+ if (current.has(colName)) {
+ current.delete(colName);
+ } else {
+ current.add(colName);
+ }
+ const next = Array.from(current);
+ update({
+ filterColumns: next,
+ fieldName: next[0] || "",
+ });
+ };
+
+ const renderColumnCheckbox = (col: ColumnTypeInfo) => (
+
+ toggleFilterColumn(col.columnName)}
+ />
+
+
+ );
+
+ return (
+
+
+ 필터 연결 설정
+
+
+ {!hasConnection && (
+
+
+
+ 연결 탭에서 대상 컴포넌트를 먼저 연결해주세요.
+ 연결된 리스트의 컬럼 목록이 여기에 표시됩니다.
+
+
+ )}
+
+ {hasConnection && showFieldName && (
+
+
+ {columnsLoading ? (
+
+
+ 컬럼 로딩...
+
+ ) : targetColumns.length > 0 ? (
+
+ {displayedCols.length > 0 && (
+
+
카드에서 표시 중
+ {displayedCols.map(renderColumnCheckbox)}
+
+ )}
+ {displayedCols.length > 0 && otherCols.length > 0 && (
+
+ )}
+ {otherCols.length > 0 && (
+
+
기타 컬럼
+ {otherCols.map(renderColumnCheckbox)}
+
+ )}
+
+ ) : (
+
+ 연결된 테이블에서 컬럼을 찾을 수 없습니다
+
+ )}
+ {selectedFilterCols.length === 0 && hasConnection && !columnsLoading && targetColumns.length > 0 && (
+
+
+
+ 필터 대상 컬럼을 선택해야 연결된 리스트에서 검색이 작동합니다
+
+
+ )}
+ {selectedFilterCols.length > 0 && (
+
+ {selectedFilterCols.length}개 컬럼 선택됨 - 검색어가 선택된 모든 컬럼에서 매칭됩니다
+
+ )}
+ {selectedFilterCols.length === 0 && (
+
+ 연결된 리스트에서 이 검색값과 매칭할 컬럼 (복수 선택 가능)
+
+ )}
+
+ )}
+
+ {fixedFilterMode ? (
+
+
+
+ {SEARCH_FILTER_MODE_LABELS[fixedFilterMode]}
+
+
+ 이 입력 타입은 {SEARCH_FILTER_MODE_LABELS[fixedFilterMode]} 방식이 자동 적용됩니다
+
+
+ ) : (
+
+
+
+
+ 연결된 리스트에 값을 보낼 때 적용되는 매칭 방식
+
+
+ )}
+
+ );
+}
+
// ========================================
// text/number 상세 설정
// ========================================
-function TextDetailSettings({ cfg, update }: StepProps) {
+function TextDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
return (
@@ -285,6 +561,8 @@ function TextDetailSettings({ cfg, update }: StepProps) {
/>
+
+
);
}
@@ -293,7 +571,7 @@ function TextDetailSettings({ cfg, update }: StepProps) {
// select 상세 설정
// ========================================
-function SelectDetailSettings({ cfg, update }: StepProps) {
+function SelectDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
const options = cfg.options || [];
const addOption = () => {
@@ -329,6 +607,8 @@ function SelectDetailSettings({ cfg, update }: StepProps) {
옵션 추가
+
+
);
}
@@ -337,7 +617,7 @@ function SelectDetailSettings({ cfg, update }: StepProps) {
// date-preset 상세 설정
// ========================================
-function DatePresetDetailSettings({ cfg, update }: StepProps) {
+function DatePresetDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
const ALL_PRESETS: DatePresetOption[] = ["today", "this-week", "this-month", "custom"];
const activePresets = cfg.datePresets || ["today", "this-week", "this-month"];
@@ -366,6 +646,8 @@ function DatePresetDetailSettings({ cfg, update }: StepProps) {
"직접" 선택 시 날짜 입력 UI가 표시됩니다 (후속 구현)
)}
+
+
);
}
@@ -694,6 +976,8 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
연결된 리스트를 필터할 때 사용할 값 (예: 회사코드)
+
+
>
)}
diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts
index 6c49b1c5..1673d027 100644
--- a/frontend/lib/registry/pop-components/pop-search/types.ts
+++ b/frontend/lib/registry/pop-components/pop-search/types.ts
@@ -46,6 +46,9 @@ export type ModalDisplayStyle = "table" | "icon";
/** 모달 검색 방식 */
export type ModalSearchMode = "contains" | "starts-with" | "equals";
+/** 검색 값을 대상 리스트에 전달할 때의 필터링 방식 */
+export type SearchFilterMode = "contains" | "equals" | "starts_with" | "range";
+
/** 모달 필터 탭 (가나다 초성 / ABC 알파벳) */
export type ModalFilterTab = "korean" | "alphabet";
@@ -93,6 +96,12 @@ export interface PopSearchConfig {
// 스타일
labelPosition?: "top" | "left";
+
+ // 연결된 리스트에 필터를 보낼 때의 매칭 방식
+ filterMode?: SearchFilterMode;
+
+ // 필터 대상 컬럼 복수 선택 (fieldName은 대표 컬럼, filterColumns는 전체 대상)
+ filterColumns?: string[];
}
/** 기본 설정값 (레지스트리 + 컴포넌트 공유) */
@@ -147,6 +156,14 @@ export const MODAL_FILTER_TAB_LABELS: Record = {
alphabet: "ABC",
};
+/** 검색 필터 방식 라벨 (설정 패널용) */
+export const SEARCH_FILTER_MODE_LABELS: Record = {
+ contains: "포함",
+ equals: "일치",
+ starts_with: "시작",
+ range: "범위",
+};
+
/** 한글 초성 추출 */
const KOREAN_CONSONANTS = [
"ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ",