Files
vexplor/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx
kjs 9d7ec613db fix: update default visibility settings for buttons in V2SplitPanelLayoutConfigPanel
- Changed default state for the search and add buttons in both left and right panels to false.
- Updated the default state for the edit button in both panels to true.
- Updated the default state for the delete button in both panels to true.

These adjustments aim to improve the initial configuration experience for users by setting more appropriate defaults for button visibility.
2026-03-17 14:31:45 +09:00

2670 lines
101 KiB
TypeScript

"use client";
/**
* V2SplitPanelLayout 설정 패널
* 토스식 단계별 UX: 관계타입 카드선택 -> 레이아웃 -> 좌측패널 -> 우측패널 -> 추가탭 -> 고급설정
* 기존 SplitPanelLayoutConfigPanel의 모든 기능을 자체 UI로 완전 구현
*/
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 {
MAX_LOAD_ALL_SIZE,
type SplitPanelLayoutConfig,
type 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 (
<div
ref={setNodeRef}
style={style}
className={cn(
"bg-card flex items-center gap-1.5 rounded-md border px-2 py-1.5",
isDragging && "z-50 opacity-50 shadow-md",
isEntityJoin && "border-primary/20 bg-primary/5"
)}
>
<div
{...attributes}
{...listeners}
className="text-muted-foreground hover:text-foreground cursor-grab touch-none"
>
<GripVertical className="h-3 w-3" />
</div>
{isEntityJoin ? (
<Link2 className="text-primary h-3 w-3 shrink-0" />
) : (
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">
#{index + 1}
</span>
)}
<Input
value={col.label}
onChange={(e) => onLabelChange(e.target.value)}
placeholder="라벨"
className="h-6 min-w-0 flex-1 text-xs"
/>
<Input
value={col.width || ""}
onChange={(e) => onWidthChange(parseInt(e.target.value) || 20)}
placeholder="20"
className="h-6 w-14 shrink-0 text-xs"
min={5}
max={100}
/>
<span className="text-muted-foreground shrink-0 text-[10px]">%</span>
{isNumeric && (
<label
className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]"
title="천 단위 구분자"
>
<input
type="checkbox"
checked={col.format?.thousandSeparator ?? false}
onChange={(e) => onFormatChange(e.target.checked)}
className="h-3 w-3"
/>
,
</label>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={onRemove}
className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
);
}
// ─── 섹션 헤더 컴포넌트 ───
function SectionHeader({
icon: Icon,
title,
description,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description?: string;
}) {
return (
<div className="space-y-1">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">{title}</h3>
</div>
{description && (
<p className="text-muted-foreground text-[10px]">{description}</p>
)}
</div>
);
}
// ─── 수평 Switch Row (토스 패턴) ───
function SwitchRow({
label,
description,
checked,
onCheckedChange,
}: {
label: string;
description?: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}) {
return (
<div className="flex items-center justify-between py-1">
<div className="space-y-0.5">
<p className="text-sm">{label}</p>
{description && (
<p className="text-[11px] text-muted-foreground">{description}</p>
)}
</div>
<Switch checked={checked} onCheckedChange={onCheckedChange} />
</div>
);
}
// ─── 관계 타입 카드 정의 ───
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.displayName || 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 (
<div className="space-y-3">
{/* 컬럼 선택 체크박스 리스트 */}
{availableColumns.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Columns3 className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium"> </span>
</div>
<div className="max-h-40 space-y-0.5 overflow-y-auto rounded-md border p-2">
{availableColumns.map((col) => {
const isAdded = currentColumns.some(
(c) => c.name === col.columnName
);
return (
<div
key={col.columnName}
className={cn(
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
isAdded && "bg-primary/10"
)}
onClick={() => {
if (isAdded) removeColumn(col.columnName);
else addColumn(col);
}}
>
<Checkbox
checked={isAdded}
className="pointer-events-none h-3.5 w-3.5"
/>
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<span className="truncate text-xs">
{col.displayName || col.columnName}
</span>
<span className="ml-auto text-[10px] text-muted-foreground/70">
{col.input_type || col.dataType}
</span>
</div>
);
})}
</div>
</div>
)}
{/* Entity 조인 컬럼 */}
{entityJoinData.joinTables.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium">Entity </span>
{loadingEntityJoins && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
</div>
<div className="space-y-2">
{entityJoinData.joinTables.map((joinTable, idx) => (
<div key={idx} className="space-y-1">
<div className="flex items-center gap-2 text-[10px] font-medium text-primary">
<Link2 className="h-3 w-3" />
<span>{joinTable.tableName}</span>
<Badge variant="outline" className="text-[10px]">
{joinTable.currentDisplayColumn}
</Badge>
</div>
<div className="max-h-28 space-y-0.5 overflow-y-auto rounded-md border border-primary/20 bg-primary/5 p-2">
{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 (
<div
key={jIdx}
className={cn(
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-primary/10",
isAdded && "bg-primary/10"
)}
onClick={() => {
if (isAdded)
removeColumn(matchingJoinColumn.joinAlias);
else addEntityColumn(matchingJoinColumn);
}}
>
<Checkbox
checked={isAdded}
className="pointer-events-none h-3.5 w-3.5"
/>
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
<span className="truncate text-xs">
{jCol.columnLabel}
</span>
<span className="ml-auto text-[10px] text-primary/80">
{jCol.inputType || jCol.dataType}
</span>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
)}
{/* 선택된 컬럼 DnD 정렬 */}
{currentColumns.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<GripVertical className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium">
({currentColumns.length})
</span>
</div>
<DndContext
collisionDetection={closestCenter}
onDragEnd={(event: DragEndEvent) => {
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));
}
}}
>
<SortableContext
items={currentColumns.map((c) => c.name)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{currentColumns.map((col, idx) => (
<SortableColumnRow
key={col.name}
id={col.name}
col={col}
index={idx}
isNumeric={isNumericType(col.name)}
isEntityJoin={!!col.isEntityJoin}
onLabelChange={(v) => 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)}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
)}
</div>
);
};
// ─── 테이블 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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-8 w-full justify-between text-xs"
disabled={loading}
>
<div className="flex items-center gap-2 truncate">
<Database className="h-3 w-3 shrink-0" />
<span className="truncate">
{loading
? "테이블 로딩 중..."
: value
? allTables.find((t) => t.tableName === value)?.displayName ||
value
: "테이블 선택"}
</span>
</div>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs">
.
</CommandEmpty>
{screenTableName && (
<CommandGroup heading="화면 기본 테이블">
<CommandItem
value={`${screenTableName} screen-default`}
onSelect={() => {
onChange(screenTableName);
setOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
value === screenTableName ? "opacity-100" : "opacity-0"
)}
/>
<Database className="text-primary mr-2 h-3.5 w-3.5" />
{allTables.find((t) => t.tableName === screenTableName)
?.displayName || screenTableName}
</CommandItem>
</CommandGroup>
)}
<CommandGroup heading="전체 테이블">
{allTables
.filter((t) => t.tableName !== screenTableName)
.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName}`}
onSelect={() => {
onChange(table.tableName);
setOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
value === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{table.displayName}</span>
{table.displayName !== table.tableName && (
<span className="text-[10px] text-muted-foreground/70">
{table.tableName}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
// ─── 메인 컴포넌트 ───
interface V2SplitPanelLayoutConfigPanelProps {
config: SplitPanelLayoutConfig;
onChange: (config: SplitPanelLayoutConfig) => void;
tables?: TableInfo[];
screenTableName?: string;
menuObjid?: number;
}
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<string, ColumnInfo[]>
>({});
const [loadingColumns, setLoadingColumns] = useState<
Record<string, boolean>
>({});
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<string, boolean>
>({});
// 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<SplitPanelLayoutConfig>) => {
handleChange({ ...config, ...updates });
},
[handleChange, config]
);
const updateLeftPanel = useCallback(
(updates: Partial<SplitPanelLayoutConfig["leftPanel"]>) => {
handleChange({
...config,
leftPanel: { ...config.leftPanel, ...updates },
});
},
[handleChange, config]
);
const updateRightPanel = useCallback(
(updates: Partial<SplitPanelLayoutConfig["rightPanel"]>) => {
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 = (columnsResponse || []).map((col: any) => ({
tableName: col.tableName || tableName,
columnName: col.columnName || col.column_name,
displayName:
col.displayName ||
col.columnLabel ||
col.column_label ||
col.columnName ||
col.column_name,
dataType: col.dataType || col.data_type || col.dbType || "",
dbType: col.dbType || col.dataType || col.data_type || "",
webType: col.webType || col.web_type || "text",
inputType: col.inputType || "direct",
input_type: col.input_type || col.inputType,
isNullable: col.isNullable === true || col.isNullable === "Y",
isPrimaryKey: col.isPrimaryKey ?? false,
referenceTable: col.referenceTable || col.reference_table,
})) as ColumnInfo[];
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<AdditionalTabConfig>) => {
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 (
<div className="space-y-4">
{/* ═══════════════════════════════════════ */}
{/* 1단계: 관계 타입 선택 (카드 UI) */}
{/* ═══════════════════════════════════════ */}
<div className="space-y-3">
<SectionHeader
icon={SplitSquareHorizontal}
title="패널 관계 타입"
description="좌측 선택 시 우측에 어떻게 데이터를 보여줄지 결정합니다"
/>
<Separator />
<div className="grid grid-cols-2 gap-2">
{RELATION_CARDS.map((card) => {
const Icon = card.icon;
const isSelected = relationshipType === card.value;
return (
<button
key={card.value}
type="button"
onClick={() =>
updateRightPanel({
relation: { ...config.rightPanel?.relation, type: card.value },
})
}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<Icon className="mb-1.5 h-5 w-5 text-primary" />
<span className="text-xs font-medium leading-tight">
{card.title}
</span>
<span className="mt-0.5 text-[10px] leading-tight text-muted-foreground">
{card.description}
</span>
</button>
);
})}
</div>
</div>
{/* ═══════════════════════════════════════ */}
{/* 2단계: 레이아웃 설정 */}
{/* ═══════════════════════════════════════ */}
<div className="space-y-3">
<SectionHeader
icon={SplitSquareHorizontal}
title="레이아웃"
description="패널 비율과 크기 조절 옵션"
/>
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
</span>
<span className="text-xs font-medium">
{config.splitRatio || 30}%
</span>
</div>
<Slider
value={[config.splitRatio || 30]}
onValueChange={(value) => updateConfig({ splitRatio: value[0] })}
min={20}
max={80}
step={5}
/>
</div>
<SwitchRow
label="크기 조절 가능"
description="사용자가 드래그로 패널 크기를 변경"
checked={config.resizable ?? true}
onCheckedChange={(checked) => updateConfig({ resizable: checked })}
/>
<SwitchRow
label="자동 데이터 로드"
description="화면 진입 시 자동으로 데이터 로드"
checked={config.autoLoad ?? true}
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
/>
</div>
{/* ═══════════════════════════════════════ */}
{/* 3단계: 좌측 패널 (접이식) */}
{/* ═══════════════════════════════════════ */}
<Collapsible open={leftPanelOpen} onOpenChange={setLeftPanelOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<PanelLeft className="h-4 w-4 text-muted-foreground" />
<div>
<span className="text-sm font-medium truncate"> ()</span>
<p className="text-[10px] text-muted-foreground truncate">
{leftTableName || "미설정"}
</p>
</div>
<Badge variant="secondary" className="text-[10px] h-5">{config.leftPanel?.columns?.length || 0} </Badge>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
leftPanelOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-4 rounded-b-lg border border-t-0 p-4">
{/* 좌측 패널 제목 */}
<div className="space-y-1.5">
<Label className="text-xs truncate"> </Label>
<Input
value={config.leftPanel?.title || ""}
onChange={(e) => updateLeftPanel({ title: e.target.value })}
placeholder="좌측 패널 제목"
className="h-8 text-xs"
/>
</div>
{/* 좌측 테이블 선택 */}
<div className="space-y-1.5">
<Label className="text-xs truncate"> </Label>
<TableCombobox
value={leftTableName}
allTables={allTables}
screenTableName={screenTableName}
loading={loadingTables}
onChange={(tableName) =>
updateLeftPanel({ tableName, columns: [] })
}
/>
{screenTableName &&
leftTableName !== screenTableName && (
<div className="flex items-center justify-between rounded bg-amber-50 px-2 py-1 dark:bg-amber-950/30">
<span className="text-[10px] text-amber-700 dark:text-amber-400">
({screenTableName})
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-[10px] text-amber-700"
onClick={() =>
updateLeftPanel({
tableName: screenTableName,
columns: [],
})
}
>
</Button>
</div>
)}
</div>
{/* 표시 모드 */}
<div className="space-y-1.5">
<Label className="text-xs truncate"> </Label>
<div className="grid grid-cols-3 gap-1.5">
{DISPLAY_MODE_CARDS.map((card) => {
const Icon = card.icon;
const currentMode =
config.leftPanel?.displayMode || "list";
const isSelected = currentMode === card.value;
return (
<button
key={card.value}
type="button"
onClick={() =>
updateLeftPanel({ displayMode: card.value })
}
className={cn(
"flex flex-col items-center rounded-md border p-2 text-center transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50"
)}
>
<Icon className="mb-1 h-4 w-4 text-primary" />
<span className="text-[10px] font-medium">
{card.title}
</span>
</button>
);
})}
</div>
</div>
{/* 좌측 패널 기능 토글 */}
<div className="space-y-1">
<SwitchRow
label="검색"
checked={config.leftPanel?.showSearch ?? false}
onCheckedChange={(checked) =>
updateLeftPanel({ showSearch: checked })
}
/>
<SwitchRow
label="추가 버튼"
checked={config.leftPanel?.showAdd ?? false}
onCheckedChange={(checked) =>
updateLeftPanel({ showAdd: checked })
}
/>
<SwitchRow
label="수정 버튼"
checked={config.leftPanel?.showEdit ?? true}
onCheckedChange={(checked) =>
updateLeftPanel({ showEdit: checked })
}
/>
<SwitchRow
label="삭제 버튼"
checked={config.leftPanel?.showDelete ?? true}
onCheckedChange={(checked) =>
updateLeftPanel({ showDelete: checked })
}
/>
<SwitchRow
label="하위 항목 추가 버튼"
description="각 항목에 + 버튼 표시"
checked={config.leftPanel?.showItemAddButton ?? false}
onCheckedChange={(checked) =>
updateLeftPanel({ showItemAddButton: checked })
}
/>
<SwitchRow
label="페이징 처리"
description="서버 페이지 단위 조회 (필터/정렬/계층 비활성화)"
checked={config.leftPanel?.pagination?.enabled ?? false}
onCheckedChange={(checked) =>
updateLeftPanel({
pagination: {
...config.leftPanel?.pagination,
enabled: checked,
pageSize: config.leftPanel?.pagination?.pageSize ?? 20,
},
})
}
/>
{config.leftPanel?.pagination?.enabled && (
<div className="ml-4 space-y-1">
<Label className="text-[10px]"> </Label>
<Input
type="number"
min={1}
max={MAX_LOAD_ALL_SIZE}
value={config.leftPanel?.pagination?.pageSize ?? 20}
onChange={(e) =>
updateLeftPanel({
pagination: {
...config.leftPanel?.pagination,
enabled: true,
pageSize: Math.min(MAX_LOAD_ALL_SIZE, Math.max(1, Number(e.target.value) || 20)),
},
})
}
className="h-7 w-24 text-xs"
/>
</div>
)}
</div>
{/* 좌측 패널 컬럼 설정 (접이식) */}
{config.leftPanel?.displayMode !== "custom" && (
<Collapsible
open={leftColumnsOpen}
onOpenChange={setLeftColumnsOpen}
>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-md border bg-muted/20 px-3 py-2 text-left transition-colors hover:bg-muted/40"
>
<div className="flex items-center gap-2">
<Columns3 className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium">
({config.leftPanel?.columns?.length || 0})
</span>
</div>
<ChevronDown
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-200",
leftColumnsOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 rounded-md border p-3">
{loadingColumns[leftTableName] ? (
<div className="flex items-center gap-2 py-4 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : leftTableColumns.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">
</p>
) : (
<PanelColumnSection
panelKey="leftPanel"
columns={config.leftPanel?.columns}
availableColumns={leftTableColumns}
entityJoinData={leftEntityJoins}
loadingEntityJoins={
loadingEntityJoins[leftTableName] || false
}
tableName={leftTableName}
onColumnsChange={(columns) =>
updateLeftPanel({ columns })
}
/>
)}
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* 좌측 패널 데이터 필터 (접이식) */}
<Collapsible
open={leftFilterOpen}
onOpenChange={setLeftFilterOpen}
>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-md border bg-muted/20 px-3 py-2 text-left transition-colors hover:bg-muted/40"
>
<div className="flex items-center gap-2">
<Search className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-200",
leftFilterOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 rounded-md border p-3">
<DataFilterConfigPanel
tableName={leftTableName}
columns={leftTableColumns}
config={config.leftPanel?.dataFilter}
onConfigChange={(dataFilter) =>
updateLeftPanel({ dataFilter })
}
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</CollapsibleContent>
</Collapsible>
{/* ═══════════════════════════════════════ */}
{/* 4단계: 우측 패널 (접이식) */}
{/* ═══════════════════════════════════════ */}
<Collapsible open={rightPanelOpen} onOpenChange={setRightPanelOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<PanelRight className="h-4 w-4 text-muted-foreground" />
<div>
<span className="text-sm font-medium truncate">
()
</span>
<p className="text-[10px] text-muted-foreground truncate">
{rightTableName || "미설정"}
</p>
</div>
<Badge variant="secondary" className="text-[10px] h-5">{config.rightPanel?.columns?.length || 0} </Badge>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
rightPanelOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-4 rounded-b-lg border border-t-0 p-4">
{/* 우측 패널 제목 */}
<div className="space-y-1.5">
<Label className="text-xs truncate"> </Label>
<Input
value={config.rightPanel?.title || ""}
onChange={(e) => updateRightPanel({ title: e.target.value })}
placeholder="우측 패널 제목"
className="h-8 text-xs"
/>
</div>
{/* 우측 테이블 선택 */}
<div className="space-y-1.5">
<Label className="text-xs truncate"> </Label>
<TableCombobox
value={rightTableName}
allTables={allTables}
screenTableName={screenTableName}
loading={loadingTables}
onChange={(tableName) =>
updateRightPanel({ tableName, columns: [] })
}
/>
</div>
{/* 표시 모드 */}
<div className="space-y-1.5">
<Label className="text-xs truncate"> </Label>
<div className="grid grid-cols-3 gap-1.5">
{DISPLAY_MODE_CARDS.map((card) => {
const Icon = card.icon;
const currentMode =
config.rightPanel?.displayMode || "list";
const isSelected = currentMode === card.value;
return (
<button
key={card.value}
type="button"
onClick={() =>
updateRightPanel({ displayMode: card.value })
}
className={cn(
"flex flex-col items-center rounded-md border p-2 text-center transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50"
)}
>
<Icon className="mb-1 h-4 w-4 text-primary" />
<span className="text-[10px] font-medium">
{card.title}
</span>
</button>
);
})}
</div>
</div>
{/* 연결 키 설정 */}
{rightTableName && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium"> </span>
</div>
<p className="text-[10px] text-muted-foreground">
</p>
{/* 기존 키 목록 */}
{(config.rightPanel?.relation?.keys || []).map(
(key, idx) => (
<div key={idx} className="flex items-center gap-2">
<Select
value={key.leftColumn || ""}
onValueChange={(v) => {
const keys = [
...(config.rightPanel?.relation?.keys || []),
];
keys[idx] = { ...keys[idx], leftColumn: v };
updateRightPanel({
relation: {
...config.rightPanel?.relation,
keys,
},
});
}}
>
<SelectTrigger className="h-7 flex-1 text-[11px]">
<SelectValue placeholder="좌측 컬럼" />
</SelectTrigger>
<SelectContent>
{leftTableColumns.map((col) => (
<SelectItem
key={col.columnName}
value={col.columnName}
>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<ArrowRight className="h-3 w-3 shrink-0 text-muted-foreground" />
<Select
value={key.rightColumn || ""}
onValueChange={(v) => {
const keys = [
...(config.rightPanel?.relation?.keys || []),
];
keys[idx] = { ...keys[idx], rightColumn: v };
updateRightPanel({
relation: {
...config.rightPanel?.relation,
keys,
},
});
}}
>
<SelectTrigger className="h-7 flex-1 text-[11px]">
<SelectValue placeholder="우측 컬럼" />
</SelectTrigger>
<SelectContent>
{rightTableColumns.map((col) => (
<SelectItem
key={col.columnName}
value={col.columnName}
>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const keys =
config.rightPanel?.relation?.keys?.filter(
(_, i) => i !== idx
) || [];
updateRightPanel({
relation: {
...config.rightPanel?.relation,
keys,
},
});
}}
className="text-destructive h-7 w-7 shrink-0 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)
)}
{/* 키가 없을 때 단일키 호환 */}
{(!config.rightPanel?.relation?.keys ||
config.rightPanel.relation.keys.length === 0) && (
<div className="flex items-center gap-2">
<Select
value={
config.rightPanel?.relation?.leftColumn || ""
}
onValueChange={(v) =>
updateRightPanel({
relation: {
...config.rightPanel?.relation,
leftColumn: v,
},
})
}
>
<SelectTrigger className="h-7 flex-1 text-[11px]">
<SelectValue placeholder="좌측 컬럼" />
</SelectTrigger>
<SelectContent>
{leftTableColumns.map((col) => (
<SelectItem
key={col.columnName}
value={col.columnName}
>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<ArrowRight className="h-3 w-3 shrink-0 text-muted-foreground" />
<Select
value={
config.rightPanel?.relation?.rightColumn || ""
}
onValueChange={(v) =>
updateRightPanel({
relation: {
...config.rightPanel?.relation,
rightColumn: v,
},
})
}
>
<SelectTrigger className="h-7 flex-1 text-[11px]">
<SelectValue placeholder="우측 컬럼" />
</SelectTrigger>
<SelectContent>
{rightTableColumns.map((col) => (
<SelectItem
key={col.columnName}
value={col.columnName}
>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const currentKeys =
config.rightPanel?.relation?.keys || [];
updateRightPanel({
relation: {
...config.rightPanel?.relation,
keys: [
...currentKeys,
{ leftColumn: "", rightColumn: "" },
],
},
});
}}
className="h-7 w-full text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
)}
{/* 우측 패널 기능 토글 */}
<div className="space-y-1">
<SwitchRow
label="검색"
checked={config.rightPanel?.showSearch ?? false}
onCheckedChange={(checked) =>
updateRightPanel({ showSearch: checked })
}
/>
<SwitchRow
label="추가 버튼"
checked={config.rightPanel?.showAdd ?? false}
onCheckedChange={(checked) =>
updateRightPanel({ showAdd: checked })
}
/>
<SwitchRow
label="수정 버튼"
checked={config.rightPanel?.showEdit ?? true}
onCheckedChange={(checked) =>
updateRightPanel({ showEdit: checked })
}
/>
<SwitchRow
label="삭제 버튼"
checked={config.rightPanel?.showDelete ?? true}
onCheckedChange={(checked) =>
updateRightPanel({ showDelete: checked })
}
/>
<SwitchRow
label="페이징 처리"
description="서버 페이지 단위 조회 (탭 포함 적용)"
checked={config.rightPanel?.pagination?.enabled ?? false}
onCheckedChange={(checked) =>
updateRightPanel({
pagination: {
...config.rightPanel?.pagination,
enabled: checked,
pageSize: config.rightPanel?.pagination?.pageSize ?? 20,
},
})
}
/>
{config.rightPanel?.pagination?.enabled && (
<div className="ml-4 space-y-1">
<Label className="text-[10px]"> </Label>
<Input
type="number"
min={1}
max={MAX_LOAD_ALL_SIZE}
value={config.rightPanel?.pagination?.pageSize ?? 20}
onChange={(e) =>
updateRightPanel({
pagination: {
...config.rightPanel?.pagination,
enabled: true,
pageSize: Math.min(MAX_LOAD_ALL_SIZE, Math.max(1, Number(e.target.value) || 20)),
},
})
}
className="h-7 w-24 text-xs"
/>
</div>
)}
</div>
{/* 우측 패널 컬럼 설정 (접이식) */}
{config.rightPanel?.displayMode !== "custom" && (
<Collapsible
open={rightColumnsOpen}
onOpenChange={setRightColumnsOpen}
>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-md border bg-muted/20 px-3 py-2 text-left transition-colors hover:bg-muted/40"
>
<div className="flex items-center gap-2">
<Columns3 className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium">
(
{config.rightPanel?.columns?.length || 0})
</span>
</div>
<ChevronDown
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-200",
rightColumnsOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 rounded-md border p-3">
{loadingColumns[rightTableName] ? (
<div className="flex items-center gap-2 py-4 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : rightTableColumns.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">
</p>
) : (
<PanelColumnSection
panelKey="rightPanel"
columns={config.rightPanel?.columns}
availableColumns={rightTableColumns}
entityJoinData={rightEntityJoins}
loadingEntityJoins={
loadingEntityJoins[rightTableName] || false
}
tableName={rightTableName}
onColumnsChange={(columns) =>
updateRightPanel({ columns })
}
/>
)}
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* 우측 패널 데이터 필터 (접이식) */}
<Collapsible
open={rightFilterOpen}
onOpenChange={setRightFilterOpen}
>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-md border bg-muted/20 px-3 py-2 text-left transition-colors hover:bg-muted/40"
>
<div className="flex items-center gap-2">
<Search className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-200",
rightFilterOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 rounded-md border p-3">
<DataFilterConfigPanel
tableName={rightTableName}
columns={rightTableColumns}
config={config.rightPanel?.dataFilter}
onConfigChange={(dataFilter) =>
updateRightPanel({ dataFilter })
}
/>
</div>
</CollapsibleContent>
</Collapsible>
{/* 우측 패널 추가 설정 (접이식) */}
<Collapsible>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-md border bg-muted/20 px-3 py-2 text-left transition-colors hover:bg-muted/40"
>
<div className="flex items-center gap-2">
<Settings className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium">
(/)
</span>
</div>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 space-y-3 rounded-md border p-3">
{/* 중복 제거 */}
<SwitchRow
label="중복 제거"
description="같은 값의 행을 하나로 합쳐서 표시"
checked={
config.rightPanel?.deduplication?.enabled ?? false
}
onCheckedChange={(checked) =>
updateRightPanel({
deduplication: {
...config.rightPanel?.deduplication,
enabled: checked,
groupByColumn:
config.rightPanel?.deduplication?.groupByColumn ||
"",
keepStrategy:
config.rightPanel?.deduplication?.keepStrategy ||
"latest",
},
})
}
/>
{config.rightPanel?.deduplication?.enabled && (
<div className="ml-4 space-y-2 border-l-2 border-primary/20 pl-3">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
</span>
<Select
value={
config.rightPanel?.deduplication
?.groupByColumn || ""
}
onValueChange={(v) =>
updateRightPanel({
deduplication: {
...config.rightPanel?.deduplication!,
groupByColumn: v,
},
})
}
>
<SelectTrigger className="h-7 w-[140px] text-[11px]">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{rightTableColumns.map((col) => (
<SelectItem
key={col.columnName}
value={col.columnName}
>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
</span>
<Select
value={
config.rightPanel?.deduplication
?.keepStrategy || "latest"
}
onValueChange={(v) =>
updateRightPanel({
deduplication: {
...config.rightPanel?.deduplication!,
keepStrategy: v as any,
},
})
}
>
<SelectTrigger className="h-7 w-[140px] text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="latest">
</SelectItem>
<SelectItem value="earliest">
</SelectItem>
<SelectItem value="base_price">
</SelectItem>
<SelectItem value="current_date">
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
<Separator />
{/* 수정 버튼 설정 */}
<SwitchRow
label="수정 버튼 모달"
description="별도 화면으로 수정 모달 표시"
checked={
config.rightPanel?.editButton?.mode === "modal"
}
onCheckedChange={(checked) =>
updateRightPanel({
editButton: {
...config.rightPanel?.editButton,
enabled:
config.rightPanel?.editButton?.enabled ?? true,
mode: checked ? "modal" : "auto",
},
})
}
/>
{config.rightPanel?.editButton?.mode === "modal" && (
<div className="ml-4 border-l-2 border-primary/20 pl-3">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
ID
</span>
<Input
type="number"
value={
config.rightPanel?.editButton?.modalScreenId ||
""
}
onChange={(e) =>
updateRightPanel({
editButton: {
...config.rightPanel?.editButton!,
modalScreenId:
parseInt(e.target.value) || undefined,
},
})
}
placeholder="화면 ID"
className="h-7 w-[100px] text-xs"
/>
</div>
</div>
)}
{/* 추가 버튼 설정 */}
<SwitchRow
label="추가 버튼 모달"
description="별도 화면으로 추가 모달 표시"
checked={
config.rightPanel?.addButton?.mode === "modal"
}
onCheckedChange={(checked) =>
updateRightPanel({
addButton: {
...config.rightPanel?.addButton,
enabled:
config.rightPanel?.addButton?.enabled ?? true,
mode: checked ? "modal" : "auto",
},
})
}
/>
{config.rightPanel?.addButton?.mode === "modal" && (
<div className="ml-4 border-l-2 border-primary/20 pl-3">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
ID
</span>
<Input
type="number"
value={
config.rightPanel?.addButton?.modalScreenId || ""
}
onChange={(e) =>
updateRightPanel({
addButton: {
...config.rightPanel?.addButton!,
modalScreenId:
parseInt(e.target.value) || undefined,
},
})
}
placeholder="화면 ID"
className="h-7 w-[100px] text-xs"
/>
</div>
</div>
)}
{/* 삭제 버튼 설정 */}
<SwitchRow
label="삭제 확인 메시지"
checked={!!config.rightPanel?.deleteButton?.confirmMessage}
onCheckedChange={(checked) =>
updateRightPanel({
deleteButton: {
...config.rightPanel?.deleteButton,
enabled:
config.rightPanel?.deleteButton?.enabled ?? true,
confirmMessage: checked
? "정말 삭제하시겠습니까?"
: undefined,
},
})
}
/>
{config.rightPanel?.deleteButton?.confirmMessage && (
<div className="ml-4 border-l-2 border-primary/20 pl-3">
<Input
value={
config.rightPanel.deleteButton.confirmMessage
}
onChange={(e) =>
updateRightPanel({
deleteButton: {
...config.rightPanel?.deleteButton!,
confirmMessage: e.target.value,
},
})
}
placeholder="삭제 확인 메시지"
className="h-7 text-xs"
/>
</div>
)}
<Separator />
{/* 추가 시 대상 테이블 (N:M 관계) */}
<div className="space-y-2">
<span className="text-xs font-medium">
(N:M)
</span>
<p className="text-[10px] text-muted-foreground">
INSERT할
</p>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
</span>
<Input
value={
config.rightPanel?.addConfig?.targetTable || ""
}
onChange={(e) =>
updateRightPanel({
addConfig: {
...config.rightPanel?.addConfig,
targetTable: e.target.value || undefined,
},
})
}
placeholder="미설정 시 우측 테이블"
className="h-7 w-[160px] text-xs"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
</span>
<Input
value={
config.rightPanel?.addConfig?.leftPanelColumn ||
""
}
onChange={(e) =>
updateRightPanel({
addConfig: {
...config.rightPanel?.addConfig,
leftPanelColumn: e.target.value || undefined,
},
})
}
placeholder="좌측 컬럼명"
className="h-7 w-[160px] text-xs"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
</span>
<Input
value={
config.rightPanel?.addConfig?.targetColumn || ""
}
onChange={(e) =>
updateRightPanel({
addConfig: {
...config.rightPanel?.addConfig,
targetColumn: e.target.value || undefined,
},
})
}
placeholder="대상 컬럼명"
className="h-7 w-[160px] text-xs"
/>
</div>
</div>
</div>
{/* 테이블 모드 설정 */}
{config.rightPanel?.displayMode === "table" && (
<>
<Separator />
<div className="space-y-1">
<span className="text-xs font-medium">
</span>
<SwitchRow
label="체크박스"
checked={
config.rightPanel?.tableConfig?.showCheckbox ??
false
}
onCheckedChange={(checked) =>
updateRightPanel({
tableConfig: {
...config.rightPanel?.tableConfig,
showCheckbox: checked,
},
})
}
/>
<SwitchRow
label="행 번호"
checked={
config.rightPanel?.tableConfig?.showRowNumber ??
false
}
onCheckedChange={(checked) =>
updateRightPanel({
tableConfig: {
...config.rightPanel?.tableConfig,
showRowNumber: checked,
},
})
}
/>
<SwitchRow
label="줄무늬"
checked={
config.rightPanel?.tableConfig?.striped ?? false
}
onCheckedChange={(checked) =>
updateRightPanel({
tableConfig: {
...config.rightPanel?.tableConfig,
striped: checked,
},
})
}
/>
<SwitchRow
label="헤더 고정"
checked={
config.rightPanel?.tableConfig?.stickyHeader ??
false
}
onCheckedChange={(checked) =>
updateRightPanel({
tableConfig: {
...config.rightPanel?.tableConfig,
stickyHeader: checked,
},
})
}
/>
</div>
</>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
</CollapsibleContent>
</Collapsible>
{/* ═══════════════════════════════════════ */}
{/* 5단계: 추가 탭 (접이식) */}
{/* ═══════════════════════════════════════ */}
<Collapsible open={tabsOpen} onOpenChange={setTabsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium truncate"> </span>
<Badge variant="secondary" className="text-[10px] h-5">{config.rightPanel?.additionalTabs?.length || 0}</Badge>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
tabsOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-3 rounded-b-lg border border-t-0 p-4">
{/* 탭 목록 */}
{(config.rightPanel?.additionalTabs || []).map(
(tab, tabIndex) => (
<div
key={tab.tabId}
className="space-y-3 rounded-lg border p-3"
>
<div className="flex items-center justify-between">
<span className="text-xs font-medium">
{tab.label || `${tabIndex + 1}`}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeTab(tabIndex)}
className="text-destructive h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={tab.label}
onChange={(e) =>
updateTab(tabIndex, { label: e.target.value })
}
placeholder="탭 이름"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={tab.title}
onChange={(e) =>
updateTab(tabIndex, { title: e.target.value })
}
placeholder="패널 제목"
className="h-7 text-xs"
/>
</div>
</div>
{/* 탭 테이블 선택 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<TableCombobox
value={tab.tableName || ""}
allTables={allTables}
screenTableName={screenTableName}
loading={loadingTables}
onChange={(tableName) => {
updateTab(tabIndex, {
tableName,
columns: [],
});
if (tableName) loadTableColumns(tableName);
}}
/>
</div>
{/* 탭 표시 모드 */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
</span>
<Select
value={tab.displayMode || "table"}
onValueChange={(v) =>
updateTab(tabIndex, {
displayMode: v as "list" | "table",
})
}
>
<SelectTrigger className="h-7 w-[100px] text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="list"></SelectItem>
<SelectItem value="table"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 탭 연결 키 */}
{tab.tableName && (
<div className="space-y-1.5">
<span className="text-[10px] font-medium"> </span>
<div className="flex items-center gap-2">
<Select
value={tab.relation?.leftColumn || ""}
onValueChange={(v) =>
updateTab(tabIndex, {
relation: {
...tab.relation,
leftColumn: v,
},
})
}
>
<SelectTrigger className="h-7 flex-1 text-[11px]">
<SelectValue placeholder="좌측 컬럼" />
</SelectTrigger>
<SelectContent>
{leftTableColumns.map((col) => (
<SelectItem
key={col.columnName}
value={col.columnName}
>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<ArrowRight className="h-3 w-3 shrink-0 text-muted-foreground" />
<Select
value={tab.relation?.rightColumn || ""}
onValueChange={(v) =>
updateTab(tabIndex, {
relation: {
...tab.relation,
rightColumn: v,
},
})
}
>
<SelectTrigger className="h-7 flex-1 text-[11px]">
<SelectValue placeholder="우측 컬럼" />
</SelectTrigger>
<SelectContent>
{(loadedTableColumns[tab.tableName] || []).map(
(col) => (
<SelectItem
key={col.columnName}
value={col.columnName}
>
{col.displayName || col.columnName}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
</div>
)}
{/* 탭 기능 토글 */}
<div className="space-y-0.5">
<SwitchRow
label="검색"
checked={tab.showSearch ?? false}
onCheckedChange={(checked) =>
updateTab(tabIndex, { showSearch: checked })
}
/>
<SwitchRow
label="추가"
checked={tab.showAdd ?? false}
onCheckedChange={(checked) =>
updateTab(tabIndex, { showAdd: checked })
}
/>
<SwitchRow
label="삭제"
checked={tab.showDelete ?? false}
onCheckedChange={(checked) =>
updateTab(tabIndex, { showDelete: checked })
}
/>
</div>
</div>
)
)}
{/* 탭 추가 버튼 */}
<Button
type="button"
variant="outline"
size="sm"
onClick={addTab}
className="h-8 w-full text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
</CollapsibleContent>
</Collapsible>
{/* ═══════════════════════════════════════ */}
{/* 6단계: 고급 설정 (기본 접힘) */}
{/* ═══════════════════════════════════════ */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium truncate"> </span>
<Badge variant="secondary" className="text-[10px] h-5">8</Badge>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-3 rounded-b-lg border border-t-0 p-4">
<SwitchRow
label="선택 동기화"
description="좌우 패널 간 선택 항목 동기화"
checked={config.syncSelection ?? false}
onCheckedChange={(checked) =>
updateConfig({ syncSelection: checked })
}
/>
<Separator />
{/* 최소 너비 설정 */}
<div className="space-y-2">
<span className="text-xs font-medium"> (px)</span>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
type="number"
value={config.minLeftWidth || 200}
onChange={(e) =>
updateConfig({
minLeftWidth: parseInt(e.target.value) || 200,
})
}
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
type="number"
value={config.minRightWidth || 300}
onChange={(e) =>
updateConfig({
minRightWidth: parseInt(e.target.value) || 300,
})
}
className="h-7 text-xs"
/>
</div>
</div>
</div>
<Separator />
{/* 좌측 패널 하위 항목 추가 설정 */}
{config.leftPanel?.showItemAddButton && (
<div className="space-y-2">
<span className="text-xs font-medium">
</span>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
</span>
<Input
value={
config.leftPanel?.itemAddConfig?.parentColumn || ""
}
onChange={(e) =>
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"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
</span>
<Input
value={
config.leftPanel?.itemAddConfig?.sourceColumn || ""
}
onChange={(e) =>
updateLeftPanel({
itemAddConfig: {
...config.leftPanel?.itemAddConfig!,
sourceColumn: e.target.value,
},
})
}
placeholder="예: dept_code"
className="h-7 w-[160px] text-xs"
/>
</div>
</div>
</div>
)}
{/* 좌측 패널 테이블 모드 설정 */}
{config.leftPanel?.displayMode === "table" && (
<div className="space-y-1">
<span className="text-xs font-medium"> </span>
<SwitchRow
label="체크박스"
checked={
config.leftPanel?.tableConfig?.showCheckbox ?? false
}
onCheckedChange={(checked) =>
updateLeftPanel({
tableConfig: {
...config.leftPanel?.tableConfig,
showCheckbox: checked,
},
})
}
/>
<SwitchRow
label="행 번호"
checked={
config.leftPanel?.tableConfig?.showRowNumber ?? false
}
onCheckedChange={(checked) =>
updateLeftPanel({
tableConfig: {
...config.leftPanel?.tableConfig,
showRowNumber: checked,
},
})
}
/>
<SwitchRow
label="줄무늬"
checked={config.leftPanel?.tableConfig?.striped ?? false}
onCheckedChange={(checked) =>
updateLeftPanel({
tableConfig: {
...config.leftPanel?.tableConfig,
striped: checked,
},
})
}
/>
<SwitchRow
label="헤더 고정"
checked={
config.leftPanel?.tableConfig?.stickyHeader ?? false
}
onCheckedChange={(checked) =>
updateLeftPanel({
tableConfig: {
...config.leftPanel?.tableConfig,
stickyHeader: checked,
},
})
}
/>
</div>
)}
{/* 좌측 패널 수정/추가 버튼 모달 설정 */}
<Separator />
<div className="space-y-1">
<span className="text-xs font-medium"> </span>
<SwitchRow
label="수정 버튼 모달"
checked={config.leftPanel?.editButton?.mode === "modal"}
onCheckedChange={(checked) =>
updateLeftPanel({
editButton: {
...config.leftPanel?.editButton,
enabled:
config.leftPanel?.editButton?.enabled ?? true,
mode: checked ? "modal" : "auto",
},
})
}
/>
{config.leftPanel?.editButton?.mode === "modal" && (
<div className="ml-4 border-l-2 border-primary/20 pl-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground">
ID
</span>
<Input
type="number"
value={
config.leftPanel?.editButton?.modalScreenId || ""
}
onChange={(e) =>
updateLeftPanel({
editButton: {
...config.leftPanel?.editButton!,
modalScreenId:
parseInt(e.target.value) || undefined,
},
})
}
placeholder="화면 ID"
className="h-7 w-[100px] text-xs"
/>
</div>
</div>
)}
<SwitchRow
label="추가 버튼 모달"
checked={config.leftPanel?.addButton?.mode === "modal"}
onCheckedChange={(checked) =>
updateLeftPanel({
addButton: {
...config.leftPanel?.addButton,
enabled:
config.leftPanel?.addButton?.enabled ?? true,
mode: checked ? "modal" : "auto",
},
})
}
/>
{config.leftPanel?.addButton?.mode === "modal" && (
<div className="ml-4 border-l-2 border-primary/20 pl-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground">
ID
</span>
<Input
type="number"
value={
config.leftPanel?.addButton?.modalScreenId || ""
}
onChange={(e) =>
updateLeftPanel({
addButton: {
...config.leftPanel?.addButton!,
modalScreenId:
parseInt(e.target.value) || undefined,
},
})
}
placeholder="화면 ID"
className="h-7 w-[100px] text-xs"
/>
</div>
</div>
)}
</div>
{/* 패널 헤더 높이 */}
<Separator />
<div className="space-y-2">
<span className="text-xs font-medium"> (px)</span>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
type="number"
value={config.leftPanel?.panelHeaderHeight || ""}
onChange={(e) =>
updateLeftPanel({
panelHeaderHeight:
parseInt(e.target.value) || undefined,
})
}
placeholder="자동"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
type="number"
value={config.rightPanel?.panelHeaderHeight || ""}
onChange={(e) =>
updateRightPanel({
panelHeaderHeight:
parseInt(e.target.value) || undefined,
})
}
placeholder="자동"
className="h-7 text-xs"
/>
</div>
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2SplitPanelLayoutConfigPanel.displayName = "V2SplitPanelLayoutConfigPanel";
export default V2SplitPanelLayoutConfigPanel;