From 38cf6172269a5f0fd48221f9f4905d849941163d Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 12 Mar 2026 02:50:18 +0900 Subject: [PATCH] [agent-pipeline] pipe-20260311174249-88g7 round-2 --- .../V2TableGroupedConfigPanel.tsx | 754 +++++++++++++++++- 1 file changed, 741 insertions(+), 13 deletions(-) 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" + /> +
+ +
+ 대상 컬럼 + +
+
+ ))} +
+ )} +
+
+
+
); };