From 08402bf730c8ecef1cab4f9695b8ab14e60e9686 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 12 Mar 2026 00:24:05 +0900 Subject: [PATCH] [agent-pipeline] pipe-20260311151253-nyk7 round-3 --- .../V2CardDisplayConfigPanel.tsx | 779 ++++++++++++++++++ .../components/v2-card-display/index.ts | 4 +- 2 files changed, 781 insertions(+), 2 deletions(-) create mode 100644 frontend/components/v2/config-panels/V2CardDisplayConfigPanel.tsx diff --git a/frontend/components/v2/config-panels/V2CardDisplayConfigPanel.tsx b/frontend/components/v2/config-panels/V2CardDisplayConfigPanel.tsx new file mode 100644 index 00000000..41fbd011 --- /dev/null +++ b/frontend/components/v2/config-panels/V2CardDisplayConfigPanel.tsx @@ -0,0 +1,779 @@ +"use client"; + +/** + * V2CardDisplay 설정 패널 + * 토스식 단계별 UX: 테이블 선택 -> 컬럼 매핑 -> 카드 스타일 -> 고급 설정(접힘) + */ + +import React, { useState, useEffect, useMemo } from "react"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +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 { + Settings, + ChevronDown, + Database, + ChevronsUpDown, + Check, + Plus, + Trash2, + Loader2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { entityJoinApi } from "@/lib/api/entityJoin"; +import { tableManagementApi } from "@/lib/api/tableManagement"; + +// ─── 한 행당 카드 수 카드 정의 ─── +const CARDS_PER_ROW_OPTIONS = [ + { value: 1, label: "1개" }, + { value: 2, label: "2개" }, + { value: 3, label: "3개" }, + { value: 4, label: "4개" }, + { value: 5, label: "5개" }, + { value: 6, label: "6개" }, +] as const; + +interface EntityJoinColumn { + tableName: string; + columnName: string; + columnLabel: string; + dataType: string; + joinAlias: string; + suggestedLabel: string; +} + +interface JoinTable { + tableName: string; + currentDisplayColumn: string; + joinConfig?: { sourceColumn: string }; + availableColumns: Array<{ + columnName: string; + columnLabel: string; + dataType: string; + description?: string; + }>; +} + +interface V2CardDisplayConfigPanelProps { + config: Record; + onChange: (config: Record) => void; + screenTableName?: string; + tableColumns?: any[]; +} + +export const V2CardDisplayConfigPanel: React.FC = ({ + config, + onChange, + screenTableName, + tableColumns = [], +}) => { + const [advancedOpen, setAdvancedOpen] = useState(false); + const [tableComboboxOpen, setTableComboboxOpen] = useState(false); + const [allTables, setAllTables] = useState>([]); + const [loadingTables, setLoadingTables] = useState(false); + const [availableColumns, setAvailableColumns] = useState([]); + const [loadingColumns, setLoadingColumns] = useState(false); + const [entityJoinColumns, setEntityJoinColumns] = useState<{ + availableColumns: EntityJoinColumn[]; + joinTables: JoinTable[]; + }>({ availableColumns: [], joinTables: [] }); + const [loadingEntityJoins, setLoadingEntityJoins] = useState(false); + + const targetTableName = useMemo(() => { + if (config.useCustomTable && config.customTableName) { + return config.customTableName; + } + return config.tableName || screenTableName; + }, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]); + + const updateConfig = (field: string, value: any) => { + const newConfig = { ...config, [field]: value }; + onChange(newConfig); + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("componentConfigChanged", { detail: { config: newConfig } }) + ); + } + }; + + const updateNestedConfig = (path: string, value: any) => { + const keys = path.split("."); + const newConfig = { ...config }; + let current = newConfig; + for (let i = 0; i < keys.length - 1; i++) { + if (!current[keys[i]]) current[keys[i]] = {}; + current[keys[i]] = { ...current[keys[i]] }; + current = current[keys[i]]; + } + current[keys[keys.length - 1]] = value; + onChange(newConfig); + }; + + // 테이블 목록 로드 + useEffect(() => { + const loadAllTables = async () => { + setLoadingTables(true); + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setAllTables( + response.data.map((t: any) => ({ + tableName: t.tableName || t.table_name, + displayName: t.tableLabel || t.displayName || t.tableName || t.table_name, + })) + ); + } + } catch { + /* 무시 */ + } finally { + setLoadingTables(false); + } + }; + loadAllTables(); + }, []); + + // 컬럼 로드 + useEffect(() => { + const loadColumns = async () => { + if (!targetTableName) { + setAvailableColumns([]); + return; + } + if (!config.useCustomTable && tableColumns && tableColumns.length > 0) { + setAvailableColumns(tableColumns); + return; + } + setLoadingColumns(true); + try { + const result = await tableManagementApi.getColumnList(targetTableName); + if (result.success && result.data?.columns) { + setAvailableColumns( + result.data.columns.map((col: any) => ({ + columnName: col.columnName, + columnLabel: col.displayName || col.columnLabel || col.columnName, + dataType: col.dataType, + })) + ); + } + } catch { + setAvailableColumns([]); + } finally { + setLoadingColumns(false); + } + }; + loadColumns(); + }, [targetTableName, config.useCustomTable, tableColumns]); + + // 엔티티 조인 컬럼 로드 + useEffect(() => { + const fetchEntityJoinColumns = async () => { + if (!targetTableName) { + setEntityJoinColumns({ availableColumns: [], joinTables: [] }); + return; + } + setLoadingEntityJoins(true); + try { + const result = await entityJoinApi.getEntityJoinColumns(targetTableName); + setEntityJoinColumns({ + availableColumns: result.availableColumns || [], + joinTables: result.joinTables || [], + }); + } catch { + setEntityJoinColumns({ availableColumns: [], joinTables: [] }); + } finally { + setLoadingEntityJoins(false); + } + }; + fetchEntityJoinColumns(); + }, [targetTableName]); + + const handleTableSelect = (tableName: string, isScreenTable: boolean) => { + if (isScreenTable) { + onChange({ + ...config, + useCustomTable: false, + customTableName: undefined, + tableName: tableName, + columnMapping: { displayColumns: [] }, + }); + } else { + onChange({ + ...config, + useCustomTable: true, + customTableName: tableName, + tableName: tableName, + columnMapping: { displayColumns: [] }, + }); + } + setTableComboboxOpen(false); + }; + + const getSelectedTableDisplay = () => { + if (!targetTableName) return "테이블을 선택하세요"; + const found = allTables.find((t) => t.tableName === targetTableName); + return found?.displayName || targetTableName; + }; + + // 컬럼 선택 시 조인 컬럼이면 joinColumns도 업데이트 + const handleColumnSelect = (path: string, columnName: string) => { + const joinColumn = entityJoinColumns.availableColumns.find( + (col) => col.joinAlias === columnName + ); + if (joinColumn) { + const joinColumnsConfig = config.joinColumns || []; + const exists = joinColumnsConfig.find((jc: any) => jc.columnName === columnName); + if (!exists) { + const joinTableInfo = entityJoinColumns.joinTables?.find( + (jt) => jt.tableName === joinColumn.tableName + ); + const newJoinColumnConfig = { + columnName: joinColumn.joinAlias, + label: joinColumn.suggestedLabel || joinColumn.columnLabel, + sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "", + referenceTable: joinColumn.tableName, + referenceColumn: joinColumn.columnName, + isJoinColumn: true, + }; + onChange({ + ...config, + columnMapping: { ...config.columnMapping, [path.split(".")[1]]: columnName }, + joinColumns: [...joinColumnsConfig, newJoinColumnConfig], + }); + return; + } + } + updateNestedConfig(path, columnName); + }; + + // 표시 컬럼 관리 + const addDisplayColumn = () => { + const current = config.columnMapping?.displayColumns || []; + updateNestedConfig("columnMapping.displayColumns", [...current, ""]); + }; + + const removeDisplayColumn = (index: number) => { + const current = [...(config.columnMapping?.displayColumns || [])]; + current.splice(index, 1); + updateNestedConfig("columnMapping.displayColumns", current); + }; + + const updateDisplayColumn = (index: number, value: string) => { + const current = [...(config.columnMapping?.displayColumns || [])]; + current[index] = value; + + const joinColumn = entityJoinColumns.availableColumns.find( + (col) => col.joinAlias === value + ); + if (joinColumn) { + const joinColumnsConfig = config.joinColumns || []; + const exists = joinColumnsConfig.find((jc: any) => jc.columnName === value); + if (!exists) { + const joinTableInfo = entityJoinColumns.joinTables?.find( + (jt) => jt.tableName === joinColumn.tableName + ); + onChange({ + ...config, + columnMapping: { ...config.columnMapping, displayColumns: current }, + joinColumns: [ + ...joinColumnsConfig, + { + columnName: joinColumn.joinAlias, + label: joinColumn.suggestedLabel || joinColumn.columnLabel, + sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "", + referenceTable: joinColumn.tableName, + referenceColumn: joinColumn.columnName, + isJoinColumn: true, + }, + ], + }); + return; + } + } + updateNestedConfig("columnMapping.displayColumns", current); + }; + + // 테이블별 조인 컬럼 그룹화 + const joinColumnsByTable: Record = {}; + entityJoinColumns.availableColumns.forEach((col) => { + if (!joinColumnsByTable[col.tableName]) joinColumnsByTable[col.tableName] = []; + joinColumnsByTable[col.tableName].push(col); + }); + + const currentTableColumns = config.useCustomTable + ? availableColumns + : tableColumns.length > 0 + ? tableColumns + : availableColumns; + + // 컬럼 선택 Select 렌더링 + const renderColumnSelect = ( + value: string, + onChangeHandler: (value: string) => void, + placeholder: string = "컬럼 선택" + ) => ( + + ); + + return ( +
+ {/* ─── 1단계: 테이블 선택 ─── */} +
+

데이터 소스

+ + + + + + + + + + 테이블을 찾을 수 없어요 + + {screenTableName && ( + + handleTableSelect(screenTableName, true)} + className="text-xs" + > + + + {allTables.find((t) => t.tableName === screenTableName)?.displayName || + screenTableName} + + + )} + + {allTables + .filter((t) => t.tableName !== screenTableName) + .map((table) => ( + handleTableSelect(table.tableName, false)} + className="text-xs" + > + + + {table.displayName} + + ))} + + + + + + {config.useCustomTable && ( +

+ 화면 기본 테이블이 아닌 다른 테이블의 데이터를 표시해요 +

+ )} +
+ + {/* ─── 2단계: 컬럼 매핑 ─── */} + {(currentTableColumns.length > 0 || loadingColumns) && ( +
+

컬럼 매핑

+ + {(loadingEntityJoins || loadingColumns) && ( +
+ + {loadingColumns ? "컬럼 로딩 중..." : "조인 컬럼 로딩 중..."} +
+ )} + +
+
+ 타이틀 +
+ {renderColumnSelect( + config.columnMapping?.titleColumn || "", + (value) => handleColumnSelect("columnMapping.titleColumn", value) + )} +
+
+ +
+ 서브타이틀 +
+ {renderColumnSelect( + config.columnMapping?.subtitleColumn || "", + (value) => handleColumnSelect("columnMapping.subtitleColumn", value) + )} +
+
+ +
+ 설명 +
+ {renderColumnSelect( + config.columnMapping?.descriptionColumn || "", + (value) => handleColumnSelect("columnMapping.descriptionColumn", value) + )} +
+
+ +
+ 이미지 +
+ {renderColumnSelect( + config.columnMapping?.imageColumn || "", + (value) => handleColumnSelect("columnMapping.imageColumn", value) + )} +
+
+
+ + {/* 표시 컬럼 */} +
+
+ 추가 표시 컬럼 + +
+ + {(config.columnMapping?.displayColumns || []).length > 0 ? ( +
+ {(config.columnMapping?.displayColumns || []).map( + (column: string, index: number) => ( +
+
+ {renderColumnSelect(column, (value) => + updateDisplayColumn(index, value) + )} +
+ +
+ ) + )} +
+ ) : ( +
+ 추가 버튼으로 표시할 컬럼을 추가해요 +
+ )} +
+
+ )} + + {/* ─── 3단계: 카드 레이아웃 ─── */} +
+

카드 레이아웃

+
+
+ 한 행당 카드 수 + +
+ +
+ 카드 간격 (px) + updateConfig("cardSpacing", parseInt(e.target.value))} + className="h-7 w-[100px] text-xs" + /> +
+
+
+ + {/* ─── 4단계: 표시 요소 토글 ─── */} +
+

표시 요소

+
+
+
+

타이틀

+

카드 상단 제목

+
+ updateNestedConfig("cardStyle.showTitle", checked)} + /> +
+ +
+
+

서브타이틀

+

제목 아래 보조 텍스트

+
+ + updateNestedConfig("cardStyle.showSubtitle", checked) + } + /> +
+ +
+
+

설명

+

카드 본문 텍스트

+
+ + updateNestedConfig("cardStyle.showDescription", checked) + } + /> +
+ +
+
+

이미지

+

카드 이미지 영역

+
+ + updateNestedConfig("cardStyle.showImage", checked) + } + /> +
+ +
+
+

액션 버튼

+

+ 상세보기, 편집, 삭제 버튼 +

+
+ + updateNestedConfig("cardStyle.showActions", checked) + } + /> +
+ + {(config.cardStyle?.showActions ?? true) && ( +
+
+ 상세보기 + + updateNestedConfig("cardStyle.showViewButton", checked) + } + /> +
+
+ 편집 + + updateNestedConfig("cardStyle.showEditButton", checked) + } + /> +
+
+ 삭제 + + updateNestedConfig("cardStyle.showDeleteButton", checked) + } + /> +
+
+ )} +
+
+ + {/* ─── 5단계: 고급 설정 (기본 접혀있음) ─── */} + + + + + +
+
+ 설명 최대 길이 + + updateNestedConfig( + "cardStyle.maxDescriptionLength", + parseInt(e.target.value) + ) + } + className="h-7 w-[100px] text-xs" + /> +
+ +
+
+

비활성화

+

+ 카드 상호작용을 비활성화해요 +

+
+ updateConfig("disabled", checked)} + /> +
+ +
+
+

읽기 전용

+

+ 데이터 수정을 막아요 +

+
+ updateConfig("readonly", checked)} + /> +
+
+
+
+
+ ); +}; + +V2CardDisplayConfigPanel.displayName = "V2CardDisplayConfigPanel"; + +export default V2CardDisplayConfigPanel; diff --git a/frontend/lib/registry/components/v2-card-display/index.ts b/frontend/lib/registry/components/v2-card-display/index.ts index 4e219fee..fe3b9fd1 100644 --- a/frontend/lib/registry/components/v2-card-display/index.ts +++ b/frontend/lib/registry/components/v2-card-display/index.ts @@ -5,7 +5,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition import { ComponentCategory } from "@/types/component"; import type { WebType } from "@/types/screen"; import { CardDisplayComponent } from "./CardDisplayComponent"; -import { CardDisplayConfigPanel } from "./CardDisplayConfigPanel"; +import { V2CardDisplayConfigPanel } from "@/components/v2/config-panels/V2CardDisplayConfigPanel"; import { CardDisplayConfig } from "./types"; /** @@ -38,7 +38,7 @@ export const V2CardDisplayDefinition = createComponentDefinition({ staticData: [], }, defaultSize: { width: 800, height: 400 }, - configPanel: CardDisplayConfigPanel, + configPanel: V2CardDisplayConfigPanel, icon: "Grid3x3", tags: ["card", "display", "table", "grid"], version: "1.0.0",