diff --git a/frontend/components/v2/config-panels/V2TableGroupedConfigPanel.tsx b/frontend/components/v2/config-panels/V2TableGroupedConfigPanel.tsx
index 6d52d999..2fee834a 100644
--- a/frontend/components/v2/config-panels/V2TableGroupedConfigPanel.tsx
+++ b/frontend/components/v2/config-panels/V2TableGroupedConfigPanel.tsx
@@ -2,14 +2,108 @@
/**
* V2TableGrouped 설정 패널
- * 기존 TableGroupedConfigPanel의 모든 로직(테이블 Combobox, 컬럼 관리, 그룹화 설정,
- * 체크박스/페이지네이션/연결 필터 등)을 유지하면서
- * componentConfigChanged 이벤트를 추가하여 실시간 업데이트 지원
+ * 토스식 단계별 UX: 데이터 소스 -> 그룹화 설정 -> 컬럼 선택 -> 표시 설정(접힘) -> 연동 설정(접힘)
+ * 기존 TableGroupedConfigPanel의 모든 기능을 자체 UI로 완전 구현
*/
-import React from "react";
-import { TableGroupedConfigPanel } from "@/lib/registry/components/v2-table-grouped/TableGroupedConfigPanel";
-import type { TableGroupedConfig } from "@/lib/registry/components/v2-table-grouped/types";
+import React, { useState, useEffect, useCallback, useMemo } from "react";
+import { Input } from "@/components/ui/input";
+import { Switch } from "@/components/ui/switch";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
+import { Separator } from "@/components/ui/separator";
+import {
+ Table2,
+ Database,
+ Layers,
+ Columns3,
+ Check,
+ ChevronsUpDown,
+ Settings,
+ ChevronDown,
+ Loader2,
+ Link2,
+ Plus,
+ Trash2,
+ FoldVertical,
+ ArrowUpDown,
+ CheckSquare,
+ LayoutGrid,
+ Type,
+ Hash,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+import { tableTypeApi } from "@/lib/api/screen";
+import type { TableGroupedConfig, LinkedFilterConfig } from "@/lib/registry/components/v2-table-grouped/types";
+import type { ColumnConfig } from "@/lib/registry/components/v2-table-list/types";
+import {
+ groupHeaderStyleOptions,
+ checkboxModeOptions,
+ sortDirectionOptions,
+} from "@/lib/registry/components/v2-table-grouped/config";
+
+// ─── 섹션 헤더 컴포넌트 ───
+function SectionHeader({ icon: Icon, title, description }: {
+ icon: React.ComponentType<{ className?: string }>;
+ title: string;
+ description?: string;
+}) {
+ return (
+
+
+
+
{title}
+
+ {description &&
{description}
}
+
+ );
+}
+
+// ─── 수평 Switch Row (토스 패턴) ───
+function SwitchRow({ label, description, checked, onCheckedChange }: {
+ label: string;
+ description?: string;
+ checked: boolean;
+ onCheckedChange: (checked: boolean) => void;
+}) {
+ return (
+
+
+
{label}
+ {description &&
{description}
}
+
+
+
+ );
+}
+
+// ─── 수평 라벨 + 컨트롤 Row ───
+function LabeledRow({ label, description, children }: {
+ label: string;
+ description?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
{label}
+ {description &&
{description}
}
+
+ {children}
+
+ );
+}
+
+// ─── 그룹 헤더 스타일 카드 ───
+const HEADER_STYLE_CARDS = [
+ { value: "default", icon: LayoutGrid, title: "기본", description: "표준 그룹 헤더" },
+ { value: "compact", icon: FoldVertical, title: "컴팩트", description: "간결한 헤더" },
+ { value: "card", icon: Layers, title: "카드", description: "카드 스타일 헤더" },
+] as const;
interface V2TableGroupedConfigPanelProps {
config: TableGroupedConfig;
@@ -20,9 +114,9 @@ export const V2TableGroupedConfigPanel: React.FC
config,
onChange,
}) => {
- const handleChange = (newConfig: Partial) => {
+ // componentConfigChanged 이벤트 발행 래퍼
+ const handleChange = useCallback((newConfig: Partial) => {
onChange(newConfig);
-
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
@@ -30,13 +124,647 @@ export const V2TableGroupedConfigPanel: React.FC
})
);
}
- };
+ }, [onChange, config]);
+ const updateConfig = useCallback((updates: Partial) => {
+ handleChange({ ...config, ...updates });
+ }, [handleChange, config]);
+
+ const updateGroupConfig = useCallback((updates: Partial) => {
+ handleChange({
+ ...config,
+ groupConfig: { ...config.groupConfig, ...updates },
+ });
+ }, [handleChange, config]);
+
+ // ─── 상태 ───
+ const [tables, setTables] = useState>([]);
+ const [tableColumns, setTableColumns] = useState([]);
+ const [loadingTables, setLoadingTables] = useState(false);
+ const [loadingColumns, setLoadingColumns] = useState(false);
+ const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
+
+ // Collapsible 상태
+ const [displayOpen, setDisplayOpen] = useState(false);
+ const [linkedOpen, setLinkedOpen] = useState(false);
+
+ // ─── 실제 사용할 테이블 이름 ───
+ const targetTableName = useMemo(() => {
+ if (config.useCustomTable && config.customTableName) {
+ return config.customTableName;
+ }
+ return config.selectedTable;
+ }, [config.useCustomTable, config.customTableName, config.selectedTable]);
+
+ // ─── 테이블 목록 로드 ───
+ useEffect(() => {
+ const loadTables = async () => {
+ setLoadingTables(true);
+ try {
+ const tableList = await tableTypeApi.getTables();
+ if (tableList && Array.isArray(tableList)) {
+ setTables(
+ tableList.map((t: any) => ({
+ tableName: t.tableName || t.table_name,
+ displayName: t.displayName || t.display_name || t.tableName || t.table_name,
+ }))
+ );
+ }
+ } catch (err) {
+ console.error("테이블 목록 로드 실패:", err);
+ } finally {
+ setLoadingTables(false);
+ }
+ };
+ loadTables();
+ }, []);
+
+ // ─── 선택된 테이블의 컬럼 로드 ───
+ useEffect(() => {
+ if (!targetTableName) {
+ setTableColumns([]);
+ return;
+ }
+
+ const loadColumns = async () => {
+ setLoadingColumns(true);
+ try {
+ const columns = await tableTypeApi.getColumns(targetTableName);
+ if (columns && Array.isArray(columns)) {
+ const cols: ColumnConfig[] = columns.map((col: any, idx: number) => ({
+ columnName: col.column_name || col.columnName,
+ displayName: col.display_name || col.displayName || col.column_name || col.columnName,
+ visible: true,
+ sortable: true,
+ searchable: false,
+ align: "left" as const,
+ order: idx,
+ }));
+ setTableColumns(cols);
+
+ if (!config.columns || config.columns.length === 0) {
+ updateConfig({ columns: cols });
+ }
+ }
+ } catch (err) {
+ console.error("컬럼 로드 실패:", err);
+ } finally {
+ setLoadingColumns(false);
+ }
+ };
+ loadColumns();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [targetTableName]);
+
+ // ─── 테이블 변경 핸들러 ───
+ const handleTableChange = useCallback((newTableName: string) => {
+ if (newTableName === config.selectedTable) return;
+ updateConfig({ selectedTable: newTableName, columns: [] });
+ setTableComboboxOpen(false);
+ }, [config.selectedTable, updateConfig]);
+
+ // ─── 컬럼 가시성 토글 ───
+ const toggleColumnVisibility = useCallback((columnName: string) => {
+ const updatedColumns = (config.columns || []).map((col) =>
+ col.columnName === columnName ? { ...col, visible: !col.visible } : col
+ );
+ updateConfig({ columns: updatedColumns });
+ }, [config.columns, updateConfig]);
+
+ // ─── 합계 컬럼 토글 ───
+ const toggleSumColumn = useCallback((columnName: string) => {
+ const currentSumCols = config.groupConfig?.summary?.sumColumns || [];
+ const newSumCols = currentSumCols.includes(columnName)
+ ? currentSumCols.filter((c) => c !== columnName)
+ : [...currentSumCols, columnName];
+
+ updateGroupConfig({
+ summary: {
+ ...config.groupConfig?.summary,
+ sumColumns: newSumCols,
+ },
+ });
+ }, [config.groupConfig?.summary, updateGroupConfig]);
+
+ // ─── 연결 필터 관리 ───
+ const addLinkedFilter = useCallback(() => {
+ const newFilter: LinkedFilterConfig = {
+ sourceComponentId: "",
+ sourceField: "value",
+ targetColumn: "",
+ enabled: true,
+ };
+ updateConfig({
+ linkedFilters: [...(config.linkedFilters || []), newFilter],
+ });
+ }, [config.linkedFilters, updateConfig]);
+
+ const removeLinkedFilter = useCallback((index: number) => {
+ const filters = [...(config.linkedFilters || [])];
+ filters.splice(index, 1);
+ updateConfig({ linkedFilters: filters });
+ }, [config.linkedFilters, updateConfig]);
+
+ const updateLinkedFilter = useCallback((index: number, updates: Partial) => {
+ const filters = [...(config.linkedFilters || [])];
+ filters[index] = { ...filters[index], ...updates };
+ updateConfig({ linkedFilters: filters });
+ }, [config.linkedFilters, updateConfig]);
+
+ // ─── 렌더링 ───
return (
-
+
+ {/* ═══════════════════════════════════════ */}
+ {/* 1단계: 데이터 소스 (테이블 선택) */}
+ {/* ═══════════════════════════════════════ */}
+
+
+
+
+
updateConfig({ useCustomTable: checked })}
+ />
+
+ {config.useCustomTable ? (
+ updateConfig({ customTableName: e.target.value })}
+ placeholder="테이블명을 직접 입력하세요"
+ className="h-8 text-xs"
+ />
+ ) : (
+
+
+
+
+
+ {
+ if (value.toLowerCase().includes(search.toLowerCase())) return 1;
+ return 0;
+ }}
+ >
+
+
+ 테이블을 찾을 수 없습니다.
+
+ {tables.map((table) => (
+ handleTableChange(table.tableName)}
+ className="text-xs"
+ >
+
+
+ {table.displayName}
+ {table.displayName !== table.tableName && (
+ {table.tableName}
+ )}
+
+
+ ))}
+
+
+
+
+
+ )}
+
+
+ {/* ═══════════════════════════════════════ */}
+ {/* 2단계: 그룹화 설정 */}
+ {/* ═══════════════════════════════════════ */}
+ {targetTableName && (
+
+
+
+
+ {/* 그룹화 기준 컬럼 */}
+
+
+
+
+ {/* 그룹 라벨 형식 */}
+
+
+
+ 그룹 라벨 형식
+
+
updateGroupConfig({ groupLabelFormat: e.target.value })}
+ placeholder="{value} ({컬럼명})"
+ className="h-7 text-xs"
+ />
+
+ {"{value}"} = 그룹값, {"{컬럼명}"} = 해당 컬럼 값
+
+
+
+
updateGroupConfig({ defaultExpanded: checked })}
+ />
+
+ {/* 그룹 정렬 */}
+
+
+
+
+
+ updateGroupConfig({
+ summary: { ...config.groupConfig?.summary, showCount: checked },
+ })
+ }
+ />
+
+ {/* 합계 컬럼 */}
+ {tableColumns.length > 0 && (
+
+
+
+ 합계 표시 컬럼
+
+
그룹별 합계를 계산할 컬럼을 선택하세요
+
+ {tableColumns.map((col) => {
+ const isChecked = config.groupConfig?.summary?.sumColumns?.includes(col.columnName) ?? false;
+ return (
+
toggleSumColumn(col.columnName)}
+ >
+ toggleSumColumn(col.columnName)}
+ className="pointer-events-none h-3.5 w-3.5"
+ />
+ {col.displayName || col.columnName}
+
+ );
+ })}
+
+
+ )}
+
+ )}
+
+ {/* 테이블 미선택 안내 */}
+ {!targetTableName && (
+
+
+
테이블이 선택되지 않았습니다
+
위 데이터 소스에서 테이블을 선택하세요
+
+ )}
+
+ {/* ═══════════════════════════════════════ */}
+ {/* 3단계: 컬럼 선택 */}
+ {/* ═══════════════════════════════════════ */}
+ {targetTableName && (config.columns || tableColumns).length > 0 && (
+
+
c.visible !== false).length}개 표시)`}
+ description="표시할 컬럼을 선택하세요"
+ />
+
+
+
+ {(config.columns || tableColumns).map((col) => {
+ const isVisible = col.visible !== false;
+ return (
+
toggleColumnVisibility(col.columnName)}
+ >
+ toggleColumnVisibility(col.columnName)}
+ className="pointer-events-none h-3.5 w-3.5"
+ />
+
+ {col.displayName || col.columnName}
+
+ );
+ })}
+
+
+ )}
+
+ {/* ═══════════════════════════════════════ */}
+ {/* 4단계: 그룹 헤더 스타일 (카드 선택) */}
+ {/* ═══════════════════════════════════════ */}
+ {targetTableName && (
+
+
+
+
+
+ {HEADER_STYLE_CARDS.map((card) => {
+ const Icon = card.icon;
+ const isSelected = (config.groupHeaderStyle || "default") === card.value;
+ return (
+
+ );
+ })}
+
+
+ )}
+
+ {/* ═══════════════════════════════════════ */}
+ {/* 5단계: 표시 설정 (기본 접힘) */}
+ {/* ═══════════════════════════════════════ */}
+
+
+
+
+
+
+
+ {/* 체크박스 */}
+
+
+
+ 체크박스
+
+
+
updateConfig({ showCheckbox: checked })}
+ />
+
+ {config.showCheckbox && (
+
+
+
+
+
+ )}
+
+
+
+
+ {/* UI 옵션 */}
+
updateConfig({ showExpandAllButton: checked })}
+ />
+
+ updateConfig({ rowClickable: checked })}
+ />
+
+
+
+ {/* 높이 및 메시지 */}
+
+ updateConfig({ maxHeight: parseInt(e.target.value) || 600 })}
+ min={200}
+ max={2000}
+ className="h-7 w-[100px] text-xs"
+ />
+
+
+
+ 빈 데이터 메시지
+ updateConfig({ emptyMessage: e.target.value })}
+ placeholder="데이터가 없습니다."
+ className="h-7 text-xs"
+ />
+
+
+
+
+
+ {/* ═══════════════════════════════════════ */}
+ {/* 6단계: 연동 설정 (기본 접힘) */}
+ {/* ═══════════════════════════════════════ */}
+
+
+
+
+
+
+
+
+ 다른 컴포넌트(검색필터 등)의 선택 값으로 이 테이블을 필터링합니다
+
+
+
+
+ {(config.linkedFilters || []).length === 0 ? (
+
+ ) : (
+
+ {(config.linkedFilters || []).map((filter, idx) => (
+
+
+
필터 #{idx + 1}
+
+ updateLinkedFilter(idx, { enabled: checked })}
+ />
+
+
+
+
+
+ 소스 컴포넌트 ID
+ updateLinkedFilter(idx, { sourceComponentId: e.target.value })}
+ placeholder="예: search-filter-1"
+ className="h-6 text-xs"
+ />
+
+
+
+ 소스 필드
+ updateLinkedFilter(idx, { sourceField: e.target.value })}
+ placeholder="value"
+ className="h-6 text-xs"
+ />
+
+
+
+ 대상 컬럼
+
+
+
+ ))}
+
+ )}
+
+
+
+
);
};