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할 테이블을 지정합니다
+
+
+
+
+ {/* 테이블 모드 설정 */}
+ {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, {
+ tableName,
+ columns: [],
+ });
+ if (tableName) loadTableColumns(tableName);
+ }}
+ />
+
+
+ {/* 탭 표시 모드 */}
+
+
+ 표시 모드
+
+
+
+
+ {/* 탭 연결 키 */}
+ {tab.tableName && (
+
+
연결 키
+
+
+
+
+
+
+ )}
+
+ {/* 탭 기능 토글 */}
+
+
+ updateTab(tabIndex, { showSearch: checked })
+ }
+ />
+
+ updateTab(tabIndex, { showAdd: checked })
+ }
+ />
+
+ updateTab(tabIndex, { showDelete: checked })
+ }
+ />
+
+
+ )
+ )}
+
+ {/* 탭 추가 버튼 */}
+
+
+
+
+
+ {/* ═══════════════════════════════════════ */}
+ {/* 6단계: 고급 설정 (기본 접힘) */}
+ {/* ═══════════════════════════════════════ */}
+
+
+
+
+
+
+
+ updateConfig({ syncSelection: checked })
+ }
+ />
+
+
+
+ {/* 최소 너비 설정 */}
+
+
+
+
+ {/* 좌측 패널 하위 항목 추가 설정 */}
+ {config.leftPanel?.showItemAddButton && (
+
+ )}
+
+ {/* 좌측 패널 테이블 모드 설정 */}
+ {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"
+ />
+
+
+ )}
+
+
+ {/* 패널 헤더 높이 */}
+
+
+
+
+
+
);
};