ERP-node/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPan...

2596 lines
98 KiB
TypeScript
Raw Normal View History

"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 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 (
<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) || 100)}
placeholder="너비"
className="h-6 w-14 shrink-0 text-xs"
/>
{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 className="max-h-[250px] overflow-y-auto">
<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 ?? true}
onCheckedChange={(checked) =>
updateLeftPanel({ showSearch: checked })
}
/>
<SwitchRow
label="추가 버튼"
checked={config.leftPanel?.showAdd ?? true}
onCheckedChange={(checked) =>
updateLeftPanel({ showAdd: checked })
}
/>
<SwitchRow
label="수정 버튼"
checked={config.leftPanel?.showEdit ?? false}
onCheckedChange={(checked) =>
updateLeftPanel({ showEdit: checked })
}
/>
<SwitchRow
label="삭제 버튼"
checked={config.leftPanel?.showDelete ?? false}
onCheckedChange={(checked) =>
updateLeftPanel({ showDelete: checked })
}
/>
<SwitchRow
label="하위 항목 추가 버튼"
description="각 항목에 + 버튼 표시"
checked={config.leftPanel?.showItemAddButton ?? false}
onCheckedChange={(checked) =>
updateLeftPanel({ showItemAddButton: checked })
}
/>
</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 className="max-h-[250px] overflow-y-auto">
<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 ?? true}
onCheckedChange={(checked) =>
updateRightPanel({ showSearch: checked })
}
/>
<SwitchRow
label="추가 버튼"
checked={config.rightPanel?.showAdd ?? true}
onCheckedChange={(checked) =>
updateRightPanel({ showAdd: checked })
}
/>
<SwitchRow
label="수정 버튼"
checked={config.rightPanel?.showEdit ?? false}
onCheckedChange={(checked) =>
updateRightPanel({ showEdit: checked })
}
/>
<SwitchRow
label="삭제 버튼"
checked={config.rightPanel?.showDelete ?? false}
onCheckedChange={(checked) =>
updateRightPanel({ showDelete: checked })
}
/>
</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 className="max-h-[250px] overflow-y-auto">
<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 className="max-h-[250px] overflow-y-auto">
<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;