diff --git a/frontend/components/v2/config-panels/V2TableSearchWidgetConfigPanel.tsx b/frontend/components/v2/config-panels/V2TableSearchWidgetConfigPanel.tsx
index 588d2d8e..d477a9f9 100644
--- a/frontend/components/v2/config-panels/V2TableSearchWidgetConfigPanel.tsx
+++ b/frontend/components/v2/config-panels/V2TableSearchWidgetConfigPanel.tsx
@@ -2,66 +2,561 @@
/**
* V2TableSearchWidget 설정 패널
- * 기존 TableSearchWidgetConfigPanel의 모든 로직(필터 모드, 대상 패널, 고정 필터 등)을 유지하면서
- * componentConfigChanged 이벤트를 추가하여 실시간 업데이트 지원
+ * 토스식 단계별 UX: 대상 패널 카드 선택 -> 필터 모드 카드 선택 -> 고정 필터 목록 -> 고급 설정(접힘)
*/
-import React from "react";
-import { TableSearchWidgetConfigPanel } from "@/lib/registry/components/v2-table-search-widget/TableSearchWidgetConfigPanel";
+import React, { useState, useEffect, useCallback } from "react";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Switch } from "@/components/ui/switch";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import {
+ PanelLeft,
+ PanelRight,
+ Layers,
+ Zap,
+ Lock,
+ Plus,
+ Trash2,
+ Settings,
+ ChevronDown,
+ Search,
+ Filter,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+
+// ─── 대상 패널 위치 카드 정의 ───
+const PANEL_POSITION_CARDS = [
+ {
+ value: "left",
+ icon: PanelLeft,
+ title: "좌측 패널",
+ description: "카드 디스플레이 등",
+ },
+ {
+ value: "right",
+ icon: PanelRight,
+ title: "우측 패널",
+ description: "테이블 리스트 등",
+ },
+ {
+ value: "auto",
+ icon: Layers,
+ title: "자동",
+ description: "모든 테이블 대상",
+ },
+] as const;
+
+// ─── 필터 모드 카드 정의 ───
+const FILTER_MODE_CARDS = [
+ {
+ value: "dynamic",
+ icon: Zap,
+ title: "동적 모드",
+ description: "사용자가 직접 필터를 선택해요",
+ },
+ {
+ value: "preset",
+ icon: Lock,
+ title: "고정 모드",
+ description: "디자이너가 미리 필터를 지정해요",
+ },
+] as const;
+
+// ─── 필터 타입 옵션 ───
+const FILTER_TYPE_OPTIONS = [
+ { value: "text", label: "텍스트" },
+ { value: "number", label: "숫자" },
+ { value: "date", label: "날짜" },
+ { value: "select", label: "선택" },
+] as const;
+
+interface PresetFilter {
+ id: string;
+ columnName: string;
+ columnLabel: string;
+ filterType: "text" | "number" | "date" | "select";
+ width?: number;
+ multiSelect?: boolean;
+}
+
+// ─── 수평 Switch Row (토스 패턴) ───
+function SwitchRow({
+ label,
+ description,
+ checked,
+ onCheckedChange,
+}: {
+ label: string;
+ description?: string;
+ checked: boolean;
+ onCheckedChange: (checked: boolean) => void;
+}) {
+ return (
+
+
+
{label}
+ {description && (
+
{description}
+ )}
+
+
+
+ );
+}
+
+// ─── 섹션 헤더 컴포넌트 ───
+function SectionHeader({
+ icon: Icon,
+ title,
+ description,
+}: {
+ icon: React.ComponentType<{ className?: string }>;
+ title: string;
+ description?: string;
+}) {
+ return (
+
+
+
+
{title}
+
+ {description && (
+
{description}
+ )}
+
+ );
+}
+
+// ─── inputType에서 filterType 추출 헬퍼 ───
+function getFilterTypeFromInputType(
+ inputType: string
+): "text" | "number" | "date" | "select" {
+ if (
+ inputType.includes("number") ||
+ inputType.includes("decimal") ||
+ inputType.includes("integer")
+ ) {
+ return "number";
+ }
+ if (inputType.includes("date") || inputType.includes("time")) {
+ return "date";
+ }
+ if (
+ inputType.includes("select") ||
+ inputType.includes("dropdown") ||
+ inputType.includes("code") ||
+ inputType.includes("category")
+ ) {
+ return "select";
+ }
+ return "text";
+}
interface V2TableSearchWidgetConfigPanelProps {
- component?: any;
- config?: any;
- onUpdateProperty?: (property: string, value: any) => void;
- onChange?: (newConfig: any) => void;
+ config: Record;
+ onChange: (config: Record) => void;
tables?: any[];
}
-export function V2TableSearchWidgetConfigPanel({
- component,
- config,
- onUpdateProperty,
- onChange,
- tables,
-}: V2TableSearchWidgetConfigPanelProps) {
- const handleChange = (newConfig: any) => {
- if (onChange) {
+export const V2TableSearchWidgetConfigPanel: React.FC<
+ V2TableSearchWidgetConfigPanelProps
+> = ({ config: configProp, onChange, tables = [] }) => {
+ const config = configProp || {};
+
+ // componentConfigChanged 이벤트 발행 래퍼
+ const handleChange = useCallback(
+ (newConfig: Record) => {
onChange(newConfig);
- }
+ if (typeof window !== "undefined") {
+ window.dispatchEvent(
+ new CustomEvent("componentConfigChanged", {
+ detail: { config: newConfig },
+ })
+ );
+ }
+ },
+ [onChange]
+ );
- if (typeof window !== "undefined") {
- window.dispatchEvent(
- new CustomEvent("componentConfigChanged", {
- detail: { config: newConfig },
- })
+ // key-value 형태 업데이트 헬퍼
+ const updateField = useCallback(
+ (key: string, value: any) => {
+ handleChange({ ...config, [key]: value });
+ },
+ [handleChange, config]
+ );
+
+ // 첫 번째 테이블의 컬럼 목록
+ const availableColumns =
+ tables.length > 0 && tables[0].columns ? tables[0].columns : [];
+
+ // ─── 로컬 상태 ───
+ const [advancedOpen, setAdvancedOpen] = useState(false);
+ const [localPresetFilters, setLocalPresetFilters] = useState(
+ config.presetFilters ?? []
+ );
+
+ // config 외부 변경 시 로컬 상태 동기화
+ useEffect(() => {
+ setLocalPresetFilters(config.presetFilters ?? []);
+ }, [config.presetFilters]);
+
+ // 현재 config 값들
+ const targetPanelPosition = config.targetPanelPosition ?? "left";
+ const filterMode = config.filterMode ?? "dynamic";
+ const autoSelectFirstTable = config.autoSelectFirstTable ?? true;
+ const showTableSelector = config.showTableSelector ?? true;
+
+ // ─── 고정 필터 CRUD ───
+ const addFilter = useCallback(() => {
+ const newFilter: PresetFilter = {
+ id: `filter_${Date.now()}`,
+ columnName: "",
+ columnLabel: "",
+ filterType: "text",
+ width: 200,
+ };
+ const updated = [...localPresetFilters, newFilter];
+ setLocalPresetFilters(updated);
+ handleChange({ ...config, presetFilters: updated });
+ }, [localPresetFilters, handleChange, config]);
+
+ const removeFilter = useCallback(
+ (id: string) => {
+ const updated = localPresetFilters.filter((f) => f.id !== id);
+ setLocalPresetFilters(updated);
+ handleChange({ ...config, presetFilters: updated });
+ },
+ [localPresetFilters, handleChange, config]
+ );
+
+ const updateFilter = useCallback(
+ (id: string, field: keyof PresetFilter, value: any) => {
+ const updated = localPresetFilters.map((f) =>
+ f.id === id ? { ...f, [field]: value } : f
);
- }
- };
+ setLocalPresetFilters(updated);
+ handleChange({ ...config, presetFilters: updated });
+ },
+ [localPresetFilters, handleChange, config]
+ );
- const handleUpdateProperty = (property: string, value: any) => {
- if (onUpdateProperty) {
- onUpdateProperty(property, value);
- }
-
- if (typeof window !== "undefined") {
- window.dispatchEvent(
- new CustomEvent("componentConfigChanged", {
- detail: { property, value },
- })
+ // 컬럼 선택 시 라벨+타입 자동 설정
+ const handleColumnSelect = useCallback(
+ (filterId: string, columnName: string) => {
+ const selectedColumn = availableColumns.find(
+ (col: any) => col.columnName === columnName
);
- }
- };
+ const updated = localPresetFilters.map((f) =>
+ f.id === filterId
+ ? {
+ ...f,
+ columnName,
+ columnLabel: selectedColumn?.columnLabel || columnName,
+ filterType: getFilterTypeFromInputType(
+ selectedColumn?.inputType || "text"
+ ),
+ }
+ : f
+ );
+ setLocalPresetFilters(updated);
+ handleChange({ ...config, presetFilters: updated });
+ },
+ [availableColumns, localPresetFilters, handleChange, config]
+ );
return (
-
+
+ {/* ─── 1단계: 대상 패널 위치 선택 ─── */}
+
+
+
+
+ 어떤 패널의 테이블을 대상으로 하나요?
+
+
+ {PANEL_POSITION_CARDS.map((card) => {
+ const Icon = card.icon;
+ const isSelected = targetPanelPosition === card.value;
+ return (
+
+ );
+ })}
+
+
+
+ {/* ─── 2단계: 필터 모드 선택 ─── */}
+
+
필터를 어떻게 구성할까요?
+
+ {FILTER_MODE_CARDS.map((card) => {
+ const Icon = card.icon;
+ const isSelected = filterMode === card.value;
+ return (
+
+ );
+ })}
+
+
+
+ {/* ─── 3단계: 고정 모드 필터 목록 ─── */}
+ {filterMode === "preset" && (
+
+
+
+
+ 고정 필터 목록
+
+
+
+
+ {localPresetFilters.length === 0 ? (
+
+
+
아직 필터가 없어요
+
+ 위의 추가 버튼으로 필터를 만들어보세요
+
+
+ ) : (
+
+ {localPresetFilters.map((filter) => (
+
+ {/* 상단: 컬럼 선택 + 삭제 */}
+
+
+ {availableColumns.length > 0 ? (
+
+ ) : (
+
+ updateFilter(
+ filter.id,
+ "columnName",
+ e.target.value
+ )
+ }
+ placeholder="예: customer_name"
+ className="h-7 text-xs"
+ />
+ )}
+
+
+
+
+ {/* 하단: 필터 타입 + 너비 */}
+
+
+
+
+ 너비
+
+
+ updateFilter(
+ filter.id,
+ "width",
+ parseInt(e.target.value) || 200
+ )
+ }
+ className="h-7 w-16 text-xs"
+ min={100}
+ max={500}
+ />
+
+
+
+ {/* 표시명 (컬럼 선택 시 자동 설정, 수동 변경 가능) */}
+ {filter.columnLabel && (
+
+ 표시명: {filter.columnLabel}
+
+ )}
+
+ ))}
+
+ )}
+
+
+ 고정 모드에서는 설정 버튼이 숨겨지고 지정된 필터만 표시돼요
+
+
+ )}
+
+ {/* 동적 모드 안내 */}
+ {filterMode === "dynamic" && (
+
+
+
+ 동적 모드 안내
+
+
+ 사용자가 테이블 설정 버튼을 클릭하여 원하는 필터를 직접 선택할 수
+ 있어요. 필터 설정은 브라우저에 저장되어 다음 접속 시에도 유지돼요.
+
+
+ )}
+
+ {/* ─── 4단계: 고급 설정 (기본 접혀있음) ─── */}
+
+
+
+
+
+
+
+ updateField("autoSelectFirstTable", checked)
+ }
+ />
+
+ updateField("showTableSelector", checked)
+ }
+ />
+
+
+
+
);
-}
+};
V2TableSearchWidgetConfigPanel.displayName = "V2TableSearchWidgetConfigPanel";