diff --git a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx index e6507435..d3462802 100644 --- a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx @@ -2,15 +2,651 @@ /** * V2SplitPanelLayout 설정 패널 - * 기존 SplitPanelLayoutConfigPanel의 전체 기능을 토스식 UX로 래핑 - * - 메인 진입점: 카드 네비게이션 (기본설정/좌측패널/우측패널/추가탭) - * - 각 모달 내부: 기존 로직 100% 유지하되 Checkbox→Switch, 일부 Select→카드선택 + * 토스식 단계별 UX: 관계타입 카드선택 -> 레이아웃 -> 좌측패널 -> 우측패널 -> 추가탭 -> 고급설정 + * 기존 SplitPanelLayoutConfigPanel의 모든 기능을 자체 UI로 완전 구현 */ -import React from "react"; -import { SplitPanelLayoutConfigPanel } from "@/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel"; -import type { SplitPanelLayoutConfig } from "@/lib/registry/components/v2-split-panel-layout/types"; -import type { TableInfo } from "@/types/screen"; +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Slider } from "@/components/ui/slider"; +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 { + Database, + Link2, + GripVertical, + X, + Check, + ChevronsUpDown, + Settings, + ChevronDown, + Loader2, + Columns3, + PanelLeft, + PanelRight, + Layers, + Plus, + Trash2, + ArrowRight, + SplitSquareHorizontal, + Eye, + List, + LayoutGrid, + Search, + Pencil, + FileText, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { entityJoinApi } from "@/lib/api/entityJoin"; +import { tableTypeApi } from "@/lib/api/screen"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel"; +import { + DndContext, + closestCenter, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, + arrayMove, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import type { + SplitPanelLayoutConfig, + AdditionalTabConfig, +} from "@/lib/registry/components/v2-split-panel-layout/types"; +import type { TableInfo, ColumnInfo } from "@/types/screen"; + +// ─── DnD 정렬 가능한 컬럼 행 ─── +function SortableColumnRow({ + id, + col, + index, + isNumeric, + isEntityJoin, + onLabelChange, + onWidthChange, + onFormatChange, + onRemove, +}: { + id: string; + col: { + name: string; + label: string; + width?: number; + format?: any; + }; + index: number; + isNumeric: boolean; + isEntityJoin?: boolean; + onLabelChange: (value: string) => void; + onWidthChange: (value: number) => void; + onFormatChange: (checked: boolean) => void; + onRemove: () => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + const style = { transform: CSS.Transform.toString(transform), transition }; + + return ( +
+
+ +
+ {isEntityJoin ? ( + + ) : ( + + #{index + 1} + + )} + onLabelChange(e.target.value)} + placeholder="라벨" + className="h-6 min-w-0 flex-1 text-xs" + /> + onWidthChange(parseInt(e.target.value) || 100)} + placeholder="너비" + className="h-6 w-14 shrink-0 text-xs" + /> + {isNumeric && ( + + )} + +
+ ); +} + +// ─── 섹션 헤더 컴포넌트 ─── +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}

+ )} +
+ +
+ ); +} + +// ─── 관계 타입 카드 정의 ─── +const RELATION_CARDS = [ + { + value: "detail" as const, + icon: Eye, + title: "선택 시 표시", + description: "좌측 선택 시에만 우측 데이터 표시", + }, + { + value: "join" as const, + icon: Link2, + title: "연관 목록", + description: "미선택 시 전체 / 선택 시 필터링", + }, +] as const; + +// ─── 표시 모드 카드 정의 ─── +const DISPLAY_MODE_CARDS = [ + { + value: "list" as const, + icon: List, + title: "목록", + description: "리스트 형태로 표시", + }, + { + value: "table" as const, + icon: LayoutGrid, + title: "테이블", + description: "테이블 그리드로 표시", + }, + { + value: "custom" as const, + icon: FileText, + title: "커스텀", + description: "자유 배치 모드", + }, +] as const; + +// ─── 패널 컬럼 설정 서브 컴포넌트 ─── +const PanelColumnSection: React.FC<{ + panelKey: "leftPanel" | "rightPanel"; + columns: SplitPanelLayoutConfig["leftPanel"]["columns"]; + availableColumns: ColumnInfo[]; + entityJoinData: { + availableColumns: Array<{ + tableName: string; + columnName: string; + columnLabel: string; + dataType: string; + joinAlias: string; + suggestedLabel: string; + }>; + joinTables: Array<{ + tableName: string; + currentDisplayColumn: string; + joinConfig?: any; + availableColumns: Array<{ + columnName: string; + columnLabel: string; + dataType: string; + inputType?: string; + }>; + }>; + }; + loadingEntityJoins: boolean; + tableName: string; + onColumnsChange: ( + columns: SplitPanelLayoutConfig["leftPanel"]["columns"] + ) => void; +}> = ({ + columns, + availableColumns, + entityJoinData, + loadingEntityJoins, + tableName, + onColumnsChange, +}) => { + const currentColumns = columns || []; + + const addColumn = (colInfo: ColumnInfo) => { + if (currentColumns.some((c) => c.name === colInfo.columnName)) return; + onColumnsChange([ + ...currentColumns, + { + name: colInfo.columnName, + label: + colInfo.columnLabel || colInfo.columnName, + width: 120, + }, + ]); + }; + + const removeColumn = (name: string) => { + onColumnsChange(currentColumns.filter((c) => c.name !== name)); + }; + + const updateColumn = ( + name: string, + updates: Partial<(typeof currentColumns)[0]> + ) => { + onColumnsChange( + currentColumns.map((c) => (c.name === name ? { ...c, ...updates } : c)) + ); + }; + + const addEntityColumn = ( + joinCol: (typeof entityJoinData.availableColumns)[0] + ) => { + if (currentColumns.some((c) => c.name === joinCol.joinAlias)) return; + onColumnsChange([ + ...currentColumns, + { + name: joinCol.joinAlias, + label: joinCol.columnLabel, + width: 120, + isEntityJoin: true, + joinInfo: { + sourceTable: tableName, + sourceColumn: joinCol.joinAlias.split("_")[0] || "", + referenceTable: joinCol.tableName, + joinAlias: joinCol.joinAlias, + }, + }, + ]); + }; + + const isNumericType = (name: string) => { + const col = availableColumns.find((c) => c.columnName === name); + if (!col) return false; + const dt = (col.dataType || "").toLowerCase(); + return ( + dt.includes("int") || + dt.includes("numeric") || + dt.includes("decimal") || + dt.includes("float") || + dt.includes("double") + ); + }; + + return ( +
+ {/* 컬럼 선택 체크박스 리스트 */} + {availableColumns.length > 0 && ( +
+
+ + 컬럼 선택 +
+
+ {availableColumns.map((col) => { + const isAdded = currentColumns.some( + (c) => c.name === col.columnName + ); + return ( +
{ + if (isAdded) removeColumn(col.columnName); + else addColumn(col); + }} + > + + + + {col.columnLabel || col.columnName} + + + {(col as any).input_type || col.dataType} + +
+ ); + })} +
+
+ )} + + {/* Entity 조인 컬럼 */} + {entityJoinData.joinTables.length > 0 && ( +
+
+ + Entity 조인 컬럼 + {loadingEntityJoins && ( + + )} +
+
+ {entityJoinData.joinTables.map((joinTable, idx) => ( +
+
+ + {joinTable.tableName} + + {joinTable.currentDisplayColumn} + +
+
+ {joinTable.availableColumns.map((jCol, jIdx) => { + const matchingJoinColumn = + entityJoinData.availableColumns.find( + (jc) => + jc.tableName === joinTable.tableName && + jc.columnName === jCol.columnName + ); + if (!matchingJoinColumn) return null; + const isAdded = currentColumns.some( + (c) => c.name === matchingJoinColumn.joinAlias + ); + return ( +
{ + if (isAdded) + removeColumn(matchingJoinColumn.joinAlias); + else addEntityColumn(matchingJoinColumn); + }} + > + + + + {jCol.columnLabel} + + + {jCol.inputType || jCol.dataType} + +
+ ); + })} +
+
+ ))} +
+
+ )} + + {/* 선택된 컬럼 DnD 정렬 */} + {currentColumns.length > 0 && ( +
+
+ + + 선택된 컬럼 ({currentColumns.length}개) + +
+ { + const { active, over } = event; + if (!over || active.id === over.id) return; + const cols = [...currentColumns]; + const oldIdx = cols.findIndex((c) => c.name === active.id); + const newIdx = cols.findIndex((c) => c.name === over.id); + if (oldIdx !== -1 && newIdx !== -1) { + onColumnsChange(arrayMove(cols, oldIdx, newIdx)); + } + }} + > + c.name)} + strategy={verticalListSortingStrategy} + > +
+ {currentColumns.map((col, idx) => ( + updateColumn(col.name, { label: v })} + onWidthChange={(v) => updateColumn(col.name, { width: v })} + onFormatChange={(checked) => + updateColumn(col.name, { + format: { + ...col.format, + thousandSeparator: checked, + }, + }) + } + onRemove={() => removeColumn(col.name)} + /> + ))} +
+
+
+
+ )} +
+ ); +}; + +// ─── 테이블 Combobox ─── +const TableCombobox: React.FC<{ + value: string; + allTables: Array<{ tableName: string; displayName: string }>; + screenTableName?: string; + loading: boolean; + onChange: (tableName: string) => void; +}> = ({ value, allTables, screenTableName, loading, onChange }) => { + const [open, setOpen] = useState(false); + + return ( + + + + + + + + + + 테이블을 찾을 수 없습니다. + + {screenTableName && ( + + { + onChange(screenTableName); + setOpen(false); + }} + className="text-xs" + > + + + {allTables.find((t) => t.tableName === screenTableName) + ?.displayName || screenTableName} + + + )} + + {allTables + .filter((t) => t.tableName !== screenTableName) + .map((table) => ( + { + onChange(table.tableName); + setOpen(false); + }} + className="text-xs" + > + +
+ {table.displayName} + {table.displayName !== table.tableName && ( + + {table.tableName} + + )} +
+
+ ))} +
+
+
+
+
+ ); +}; + +// ─── 메인 컴포넌트 ─── interface V2SplitPanelLayoutConfigPanelProps { config: SplitPanelLayoutConfig; @@ -20,41 +656,1944 @@ interface V2SplitPanelLayoutConfigPanelProps { menuObjid?: number; } -/** - * V2SplitPanelLayoutConfigPanel - * 기존 SplitPanelLayoutConfigPanel을 토스식 UX 래퍼로 감싸서 제공 - * - * 기존 패널은 이미 4개 카드→Dialog 모달 패턴을 사용하고 있어 - * 토스식 구조(카드 선택, Switch, Collapsible)와 일치하므로 - * componentConfigChanged 이벤트만 추가하여 실시간 업데이트 지원 - */ -export const V2SplitPanelLayoutConfigPanel: React.FC = ({ - config, - onChange, - tables, - screenTableName, - menuObjid, -}) => { - const handleChange = (newConfig: SplitPanelLayoutConfig) => { - onChange(newConfig); +export const V2SplitPanelLayoutConfigPanel: React.FC< + V2SplitPanelLayoutConfigPanelProps +> = ({ config, onChange, tables, screenTableName, menuObjid }) => { + // ─── 상태 ─── + const [allTables, setAllTables] = useState< + Array<{ tableName: string; displayName: string }> + >([]); + const [loadingTables, setLoadingTables] = useState(false); + const [loadedTableColumns, setLoadedTableColumns] = useState< + Record + >({}); + const [loadingColumns, setLoadingColumns] = useState< + Record + >({}); + const [entityJoinColumns, setEntityJoinColumns] = useState< + Record< + string, + { + availableColumns: Array<{ + tableName: string; + columnName: string; + columnLabel: string; + dataType: string; + joinAlias: string; + suggestedLabel: string; + }>; + joinTables: Array<{ + tableName: string; + currentDisplayColumn: string; + joinConfig?: any; + availableColumns: Array<{ + columnName: string; + columnLabel: string; + dataType: string; + inputType?: string; + }>; + }>; + } + > + >({}); + const [loadingEntityJoins, setLoadingEntityJoins] = useState< + Record + >({}); - if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent("componentConfigChanged", { - detail: { config: newConfig }, - }) - ); + // Collapsible 상태 + const [leftPanelOpen, setLeftPanelOpen] = useState(false); + const [rightPanelOpen, setRightPanelOpen] = useState(false); + const [tabsOpen, setTabsOpen] = useState(false); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [leftColumnsOpen, setLeftColumnsOpen] = useState(false); + const [rightColumnsOpen, setRightColumnsOpen] = useState(false); + const [leftFilterOpen, setLeftFilterOpen] = useState(false); + const [rightFilterOpen, setRightFilterOpen] = useState(false); + + // ─── 파생 값 ─── + const relationshipType = config.rightPanel?.relation?.type || "detail"; + const leftTableName = config.leftPanel?.tableName || screenTableName || ""; + const rightTableName = config.rightPanel?.tableName || ""; + + const leftTableColumns = useMemo( + () => (leftTableName ? loadedTableColumns[leftTableName] || [] : []), + [loadedTableColumns, leftTableName] + ); + const rightTableColumns = useMemo( + () => (rightTableName ? loadedTableColumns[rightTableName] || [] : []), + [loadedTableColumns, rightTableName] + ); + + const leftEntityJoins = useMemo( + () => + entityJoinColumns[leftTableName] || { + availableColumns: [], + joinTables: [], + }, + [entityJoinColumns, leftTableName] + ); + const rightEntityJoins = useMemo( + () => + entityJoinColumns[rightTableName] || { + availableColumns: [], + joinTables: [], + }, + [entityJoinColumns, rightTableName] + ); + + // ─── 이벤트 발행 래퍼 ─── + const handleChange = useCallback( + (newConfig: SplitPanelLayoutConfig) => { + onChange(newConfig); + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("componentConfigChanged", { + detail: { config: newConfig }, + }) + ); + } + }, + [onChange] + ); + + const updateConfig = useCallback( + (updates: Partial) => { + handleChange({ ...config, ...updates }); + }, + [handleChange, config] + ); + + const updateLeftPanel = useCallback( + (updates: Partial) => { + handleChange({ + ...config, + leftPanel: { ...config.leftPanel, ...updates }, + }); + }, + [handleChange, config] + ); + + const updateRightPanel = useCallback( + (updates: Partial) => { + handleChange({ + ...config, + rightPanel: { ...config.rightPanel, ...updates }, + }); + }, + [handleChange, config] + ); + + // ─── 테이블 목록 로드 ─── + 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 (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setLoadingTables(false); + } + }; + loadAllTables(); + }, []); + + // 좌측 테이블 초기값 설정 + useEffect(() => { + if (screenTableName && !config.leftPanel?.tableName) { + updateLeftPanel({ tableName: screenTableName }); } - }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [screenTableName]); + // ─── 테이블 컬럼 로드 ─── + const loadTableColumns = useCallback( + async (tableName: string) => { + if (loadedTableColumns[tableName] || loadingColumns[tableName]) return; + setLoadingColumns((prev) => ({ ...prev, [tableName]: true })); + try { + const columnsResponse = await tableTypeApi.getColumns(tableName); + const cols: ColumnInfo[] = (columnsResponse || []).map((col: any) => ({ + tableName: col.tableName || tableName, + columnName: col.columnName || col.column_name, + columnLabel: + col.displayName || + col.columnLabel || + col.column_label || + col.columnName || + col.column_name, + dataType: col.dataType || col.data_type || col.dbType, + webType: col.webType || col.web_type, + input_type: col.inputType || col.input_type, + referenceTable: col.referenceTable || col.reference_table, + })); + setLoadedTableColumns((prev) => ({ ...prev, [tableName]: cols })); + await loadEntityJoinColumnsForTable(tableName); + } catch (error) { + console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error); + setLoadedTableColumns((prev) => ({ ...prev, [tableName]: [] })); + } finally { + setLoadingColumns((prev) => ({ ...prev, [tableName]: false })); + } + }, + [loadedTableColumns, loadingColumns] + ); + + const loadEntityJoinColumnsForTable = useCallback( + async (tableName: string) => { + if (entityJoinColumns[tableName] || loadingEntityJoins[tableName]) return; + setLoadingEntityJoins((prev) => ({ ...prev, [tableName]: true })); + try { + const result = await entityJoinApi.getEntityJoinColumns(tableName); + setEntityJoinColumns((prev) => ({ + ...prev, + [tableName]: { + availableColumns: result.availableColumns || [], + joinTables: result.joinTables || [], + }, + })); + } catch (error) { + console.error(`Entity 조인 컬럼 조회 실패 (${tableName}):`, error); + setEntityJoinColumns((prev) => ({ + ...prev, + [tableName]: { availableColumns: [], joinTables: [] }, + })); + } finally { + setLoadingEntityJoins((prev) => ({ ...prev, [tableName]: false })); + } + }, + [entityJoinColumns, loadingEntityJoins] + ); + + // 좌측/우측 테이블 변경 시 컬럼 로드 + useEffect(() => { + if (leftTableName) loadTableColumns(leftTableName); + }, [leftTableName, loadTableColumns]); + + useEffect(() => { + if (rightTableName) loadTableColumns(rightTableName); + }, [rightTableName, loadTableColumns]); + + // ─── 추가 탭 관리 ─── + const addTab = useCallback(() => { + const currentTabs = config.rightPanel?.additionalTabs || []; + const newTab: AdditionalTabConfig = { + tabId: `tab_${Date.now()}`, + label: `탭 ${currentTabs.length + 1}`, + title: `탭 ${currentTabs.length + 1}`, + }; + updateRightPanel({ + additionalTabs: [...currentTabs, newTab], + }); + }, [config.rightPanel?.additionalTabs, updateRightPanel]); + + const updateTab = useCallback( + (tabIndex: number, updates: Partial) => { + const newTabs = [...(config.rightPanel?.additionalTabs || [])]; + newTabs[tabIndex] = { ...newTabs[tabIndex], ...updates }; + updateRightPanel({ additionalTabs: newTabs }); + }, + [config.rightPanel?.additionalTabs, updateRightPanel] + ); + + const removeTab = useCallback( + (tabIndex: number) => { + const newTabs = + config.rightPanel?.additionalTabs?.filter((_, i) => i !== tabIndex) || + []; + updateRightPanel({ additionalTabs: newTabs }); + }, + [config.rightPanel?.additionalTabs, updateRightPanel] + ); + + // ─── 렌더링 ─── return ( - +
+ {/* ═══════════════════════════════════════ */} + {/* 1단계: 관계 타입 선택 (카드 UI) */} + {/* ═══════════════════════════════════════ */} +
+ + +
+ {RELATION_CARDS.map((card) => { + const Icon = card.icon; + const isSelected = relationshipType === card.value; + return ( + + ); + })} +
+
+ + {/* ═══════════════════════════════════════ */} + {/* 2단계: 레이아웃 설정 */} + {/* ═══════════════════════════════════════ */} +
+ + + +
+
+ + 좌측 패널 너비 + + + {config.splitRatio || 30}% + +
+ updateConfig({ splitRatio: value[0] })} + min={20} + max={80} + step={5} + /> +
+ + updateConfig({ resizable: checked })} + /> + + updateConfig({ autoLoad: checked })} + /> +
+ + {/* ═══════════════════════════════════════ */} + {/* 3단계: 좌측 패널 (접이식) */} + {/* ═══════════════════════════════════════ */} + + + + + +
+ {/* 좌측 패널 제목 */} +
+ + updateLeftPanel({ title: e.target.value })} + placeholder="좌측 패널 제목" + className="h-8 text-xs" + /> +
+ + {/* 좌측 테이블 선택 */} +
+ + + updateLeftPanel({ tableName, columns: [] }) + } + /> + {screenTableName && + leftTableName !== screenTableName && ( +
+ + 기본 테이블({screenTableName})과 다름 + + +
+ )} +
+ + {/* 표시 모드 */} +
+ +
+ {DISPLAY_MODE_CARDS.map((card) => { + const Icon = card.icon; + const currentMode = + config.leftPanel?.displayMode || "list"; + const isSelected = currentMode === card.value; + return ( + + ); + })} +
+
+ + {/* 좌측 패널 기능 토글 */} +
+ + updateLeftPanel({ showSearch: checked }) + } + /> + + updateLeftPanel({ showAdd: checked }) + } + /> + + updateLeftPanel({ showEdit: checked }) + } + /> + + updateLeftPanel({ showDelete: checked }) + } + /> + + updateLeftPanel({ showItemAddButton: checked }) + } + /> +
+ + {/* 좌측 패널 컬럼 설정 (접이식) */} + {config.leftPanel?.displayMode !== "custom" && ( + + + + + +
+ {loadingColumns[leftTableName] ? ( +
+ + 컬럼 로딩 중... +
+ ) : leftTableColumns.length === 0 ? ( +

+ 테이블을 선택하면 컬럼이 표시됩니다 +

+ ) : ( + + updateLeftPanel({ columns }) + } + /> + )} +
+
+
+ )} + + {/* 좌측 패널 데이터 필터 (접이식) */} + + + + + +
+ ({ + columnName: col.columnName, + columnLabel: col.columnLabel || col.columnName, + dataType: col.dataType, + input_type: (col as any).input_type, + }))} + config={config.leftPanel?.dataFilter} + onConfigChange={(dataFilter) => + updateLeftPanel({ dataFilter }) + } + /> +
+
+
+
+
+
+ + {/* ═══════════════════════════════════════ */} + {/* 4단계: 우측 패널 (접이식) */} + {/* ═══════════════════════════════════════ */} + + + + + +
+ {/* 우측 패널 제목 */} +
+ + updateRightPanel({ title: e.target.value })} + placeholder="우측 패널 제목" + className="h-8 text-xs" + /> +
+ + {/* 우측 테이블 선택 */} +
+ + + updateRightPanel({ tableName, columns: [] }) + } + /> +
+ + {/* 표시 모드 */} +
+ +
+ {DISPLAY_MODE_CARDS.map((card) => { + const Icon = card.icon; + const currentMode = + config.rightPanel?.displayMode || "list"; + const isSelected = currentMode === card.value; + return ( + + ); + })} +
+
+ + {/* 연결 키 설정 */} + {rightTableName && ( +
+
+ + 테이블 연결 키 +
+

+ 좌측 패널과 우측 패널을 연결할 컬럼을 설정합니다 +

+ + {/* 기존 키 목록 */} + {(config.rightPanel?.relation?.keys || []).map( + (key, idx) => ( +
+ + + + + + + +
+ ) + )} + + {/* 키가 없을 때 단일키 호환 */} + {(!config.rightPanel?.relation?.keys || + config.rightPanel.relation.keys.length === 0) && ( +
+ + + + + +
+ )} + + +
+ )} + + {/* 우측 패널 기능 토글 */} +
+ + updateRightPanel({ showSearch: checked }) + } + /> + + updateRightPanel({ showAdd: checked }) + } + /> + + updateRightPanel({ showEdit: checked }) + } + /> + + updateRightPanel({ showDelete: checked }) + } + /> +
+ + {/* 우측 패널 컬럼 설정 (접이식) */} + {config.rightPanel?.displayMode !== "custom" && ( + + + + + +
+ {loadingColumns[rightTableName] ? ( +
+ + 컬럼 로딩 중... +
+ ) : rightTableColumns.length === 0 ? ( +

+ 테이블을 선택하면 컬럼이 표시됩니다 +

+ ) : ( + + updateRightPanel({ columns }) + } + /> + )} +
+
+
+ )} + + {/* 우측 패널 데이터 필터 (접이식) */} + + + + + +
+ ({ + columnName: col.columnName, + columnLabel: col.columnLabel || col.columnName, + dataType: col.dataType, + input_type: (col as any).input_type, + }))} + config={config.rightPanel?.dataFilter} + onConfigChange={(dataFilter) => + updateRightPanel({ dataFilter }) + } + /> +
+
+
+ + {/* 우측 패널 추가 설정 (접이식) */} + + + + + +
+ {/* 중복 제거 */} + + updateRightPanel({ + deduplication: { + ...config.rightPanel?.deduplication, + enabled: checked, + groupByColumn: + config.rightPanel?.deduplication?.groupByColumn || + "", + keepStrategy: + config.rightPanel?.deduplication?.keepStrategy || + "latest", + }, + }) + } + /> + + {config.rightPanel?.deduplication?.enabled && ( +
+
+ + 기준 컬럼 + + +
+
+ + 유지 전략 + + +
+
+ )} + + + + {/* 수정 버튼 설정 */} + + updateRightPanel({ + editButton: { + ...config.rightPanel?.editButton, + enabled: + config.rightPanel?.editButton?.enabled ?? true, + mode: checked ? "modal" : "auto", + }, + }) + } + /> + + {config.rightPanel?.editButton?.mode === "modal" && ( +
+
+ + 모달 화면 ID + + + updateRightPanel({ + editButton: { + ...config.rightPanel?.editButton!, + modalScreenId: + parseInt(e.target.value) || undefined, + }, + }) + } + placeholder="화면 ID" + className="h-7 w-[100px] text-xs" + /> +
+
+ )} + + {/* 추가 버튼 설정 */} + + updateRightPanel({ + addButton: { + ...config.rightPanel?.addButton, + enabled: + config.rightPanel?.addButton?.enabled ?? true, + mode: checked ? "modal" : "auto", + }, + }) + } + /> + + {config.rightPanel?.addButton?.mode === "modal" && ( +
+
+ + 모달 화면 ID + + + updateRightPanel({ + addButton: { + ...config.rightPanel?.addButton!, + modalScreenId: + parseInt(e.target.value) || undefined, + }, + }) + } + placeholder="화면 ID" + className="h-7 w-[100px] text-xs" + /> +
+
+ )} + + {/* 삭제 버튼 설정 */} + + updateRightPanel({ + deleteButton: { + ...config.rightPanel?.deleteButton, + enabled: + config.rightPanel?.deleteButton?.enabled ?? true, + confirmMessage: checked + ? "정말 삭제하시겠습니까?" + : undefined, + }, + }) + } + /> + + {config.rightPanel?.deleteButton?.confirmMessage && ( +
+ + updateRightPanel({ + deleteButton: { + ...config.rightPanel?.deleteButton!, + confirmMessage: e.target.value, + }, + }) + } + placeholder="삭제 확인 메시지" + className="h-7 text-xs" + /> +
+ )} + + + + {/* 추가 시 대상 테이블 (N:M 관계) */} +
+ + 추가 대상 설정 (N:M) + +

+ 추가 버튼 클릭 시 실제 INSERT할 테이블을 지정합니다 +

+
+
+ + 대상 테이블 + + + updateRightPanel({ + addConfig: { + ...config.rightPanel?.addConfig, + targetTable: e.target.value || undefined, + }, + }) + } + placeholder="미설정 시 우측 테이블" + className="h-7 w-[160px] text-xs" + /> +
+
+ + 좌측값 컬럼 + + + updateRightPanel({ + addConfig: { + ...config.rightPanel?.addConfig, + leftPanelColumn: e.target.value || undefined, + }, + }) + } + placeholder="좌측 컬럼명" + className="h-7 w-[160px] text-xs" + /> +
+
+ + 대상 컬럼 + + + updateRightPanel({ + addConfig: { + ...config.rightPanel?.addConfig, + targetColumn: e.target.value || undefined, + }, + }) + } + placeholder="대상 컬럼명" + className="h-7 w-[160px] text-xs" + /> +
+
+
+ + {/* 테이블 모드 설정 */} + {config.rightPanel?.displayMode === "table" && ( + <> + +
+ + 테이블 옵션 + + + updateRightPanel({ + tableConfig: { + ...config.rightPanel?.tableConfig, + showCheckbox: checked, + }, + }) + } + /> + + updateRightPanel({ + tableConfig: { + ...config.rightPanel?.tableConfig, + showRowNumber: checked, + }, + }) + } + /> + + updateRightPanel({ + tableConfig: { + ...config.rightPanel?.tableConfig, + striped: checked, + }, + }) + } + /> + + updateRightPanel({ + tableConfig: { + ...config.rightPanel?.tableConfig, + stickyHeader: checked, + }, + }) + } + /> +
+ + )} +
+
+
+
+
+
+ + {/* ═══════════════════════════════════════ */} + {/* 5단계: 추가 탭 (접이식) */} + {/* ═══════════════════════════════════════ */} + + + + + +
+ {/* 탭 목록 */} + {(config.rightPanel?.additionalTabs || []).map( + (tab, tabIndex) => ( +
+
+ + {tab.label || `탭 ${tabIndex + 1}`} + + +
+ +
+
+ + + updateTab(tabIndex, { label: e.target.value }) + } + placeholder="탭 이름" + className="h-7 text-xs" + /> +
+
+ + + updateTab(tabIndex, { title: e.target.value }) + } + placeholder="패널 제목" + className="h-7 text-xs" + /> +
+
+ + {/* 탭 테이블 선택 */} +
+ + { + updateTab(tabIndex, { + tableName, + columns: [], + }); + if (tableName) loadTableColumns(tableName); + }} + /> +
+ + {/* 탭 표시 모드 */} +
+ + 표시 모드 + + +
+ + {/* 탭 연결 키 */} + {tab.tableName && ( +
+ 연결 키 +
+ + + +
+
+ )} + + {/* 탭 기능 토글 */} +
+ + updateTab(tabIndex, { showSearch: checked }) + } + /> + + updateTab(tabIndex, { showAdd: checked }) + } + /> + + updateTab(tabIndex, { showDelete: checked }) + } + /> +
+
+ ) + )} + + {/* 탭 추가 버튼 */} + +
+
+
+ + {/* ═══════════════════════════════════════ */} + {/* 6단계: 고급 설정 (기본 접힘) */} + {/* ═══════════════════════════════════════ */} + + + + + +
+ + updateConfig({ syncSelection: checked }) + } + /> + + + + {/* 최소 너비 설정 */} +
+ 최소 너비 (px) +
+
+ + + updateConfig({ + minLeftWidth: parseInt(e.target.value) || 200, + }) + } + className="h-7 text-xs" + /> +
+
+ + + updateConfig({ + minRightWidth: parseInt(e.target.value) || 300, + }) + } + className="h-7 text-xs" + /> +
+
+
+ + + + {/* 좌측 패널 하위 항목 추가 설정 */} + {config.leftPanel?.showItemAddButton && ( +
+ + 하위 항목 추가 설정 + +
+
+ + 부모 컬럼 + + + updateLeftPanel({ + itemAddConfig: { + ...config.leftPanel?.itemAddConfig, + parentColumn: e.target.value, + sourceColumn: + config.leftPanel?.itemAddConfig?.sourceColumn || + "", + }, + }) + } + placeholder="예: parent_dept_code" + className="h-7 w-[160px] text-xs" + /> +
+
+ + 소스 컬럼 + + + updateLeftPanel({ + itemAddConfig: { + ...config.leftPanel?.itemAddConfig!, + sourceColumn: e.target.value, + }, + }) + } + placeholder="예: dept_code" + className="h-7 w-[160px] text-xs" + /> +
+
+
+ )} + + {/* 좌측 패널 테이블 모드 설정 */} + {config.leftPanel?.displayMode === "table" && ( +
+ 좌측 테이블 옵션 + + updateLeftPanel({ + tableConfig: { + ...config.leftPanel?.tableConfig, + showCheckbox: checked, + }, + }) + } + /> + + updateLeftPanel({ + tableConfig: { + ...config.leftPanel?.tableConfig, + showRowNumber: checked, + }, + }) + } + /> + + updateLeftPanel({ + tableConfig: { + ...config.leftPanel?.tableConfig, + striped: checked, + }, + }) + } + /> + + updateLeftPanel({ + tableConfig: { + ...config.leftPanel?.tableConfig, + stickyHeader: checked, + }, + }) + } + /> +
+ )} + + {/* 좌측 패널 수정/추가 버튼 모달 설정 */} + +
+ 좌측 버튼 모달 설정 + + updateLeftPanel({ + editButton: { + ...config.leftPanel?.editButton, + enabled: + config.leftPanel?.editButton?.enabled ?? true, + mode: checked ? "modal" : "auto", + }, + }) + } + /> + {config.leftPanel?.editButton?.mode === "modal" && ( +
+
+ + 모달 화면 ID + + + updateLeftPanel({ + editButton: { + ...config.leftPanel?.editButton!, + modalScreenId: + parseInt(e.target.value) || undefined, + }, + }) + } + placeholder="화면 ID" + className="h-7 w-[100px] text-xs" + /> +
+
+ )} + + + updateLeftPanel({ + addButton: { + ...config.leftPanel?.addButton, + enabled: + config.leftPanel?.addButton?.enabled ?? true, + mode: checked ? "modal" : "auto", + }, + }) + } + /> + {config.leftPanel?.addButton?.mode === "modal" && ( +
+
+ + 모달 화면 ID + + + updateLeftPanel({ + addButton: { + ...config.leftPanel?.addButton!, + modalScreenId: + parseInt(e.target.value) || undefined, + }, + }) + } + placeholder="화면 ID" + className="h-7 w-[100px] text-xs" + /> +
+
+ )} +
+ + {/* 패널 헤더 높이 */} + +
+ 패널 헤더 높이 (px) +
+
+ + + updateLeftPanel({ + panelHeaderHeight: + parseInt(e.target.value) || undefined, + }) + } + placeholder="자동" + className="h-7 text-xs" + /> +
+
+ + + updateRightPanel({ + panelHeaderHeight: + parseInt(e.target.value) || undefined, + }) + } + placeholder="자동" + className="h-7 text-xs" + /> +
+
+
+
+
+
+
); };