- 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.
2670 lines
101 KiB
TypeScript
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;
|