2026-03-12 00:27:35 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* V2SplitPanelLayout 설정 패널
|
2026-03-12 04:39:57 +09:00
|
|
|
* 토스식 단계별 UX: 관계타입 카드선택 -> 레이아웃 -> 좌측패널 -> 우측패널 -> 추가탭 -> 고급설정
|
|
|
|
|
* 기존 SplitPanelLayoutConfigPanel의 모든 기능을 자체 UI로 완전 구현
|
2026-03-12 00:27:35 +09:00
|
|
|
*/
|
|
|
|
|
|
2026-03-12 04:39:57 +09:00
|
|
|
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:
|
2026-03-12 04:45:02 +09:00
|
|
|
colInfo.displayName || colInfo.columnName,
|
2026-03-12 04:39:57 +09:00
|
|
|
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">
|
2026-03-12 04:45:02 +09:00
|
|
|
{col.displayName || col.columnName}
|
2026-03-12 04:39:57 +09:00
|
|
|
</span>
|
|
|
|
|
<span className="ml-auto text-[10px] text-muted-foreground/70">
|
2026-03-12 04:45:02 +09:00
|
|
|
{col.input_type || col.dataType}
|
2026-03-12 04:39:57 +09:00
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ─── 메인 컴포넌트 ───
|
2026-03-12 00:27:35 +09:00
|
|
|
|
|
|
|
|
interface V2SplitPanelLayoutConfigPanelProps {
|
|
|
|
|
config: SplitPanelLayoutConfig;
|
|
|
|
|
onChange: (config: SplitPanelLayoutConfig) => void;
|
|
|
|
|
tables?: TableInfo[];
|
|
|
|
|
screenTableName?: string;
|
|
|
|
|
menuObjid?: number;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 04:39:57 +09:00
|
|
|
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 });
|
2026-03-12 00:27:35 +09:00
|
|
|
}
|
2026-03-12 04:39:57 +09:00
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [screenTableName]);
|
2026-03-12 00:27:35 +09:00
|
|
|
|
2026-03-12 04:39:57 +09:00
|
|
|
// ─── 테이블 컬럼 로드 ───
|
|
|
|
|
const loadTableColumns = useCallback(
|
|
|
|
|
async (tableName: string) => {
|
|
|
|
|
if (loadedTableColumns[tableName] || loadingColumns[tableName]) return;
|
|
|
|
|
setLoadingColumns((prev) => ({ ...prev, [tableName]: true }));
|
|
|
|
|
try {
|
|
|
|
|
const columnsResponse = await tableTypeApi.getColumns(tableName);
|
2026-03-12 04:45:02 +09:00
|
|
|
const cols = (columnsResponse || []).map((col: any) => ({
|
2026-03-12 04:39:57 +09:00
|
|
|
tableName: col.tableName || tableName,
|
|
|
|
|
columnName: col.columnName || col.column_name,
|
2026-03-12 04:45:02 +09:00
|
|
|
displayName:
|
2026-03-12 04:39:57 +09:00
|
|
|
col.displayName ||
|
|
|
|
|
col.columnLabel ||
|
|
|
|
|
col.column_label ||
|
|
|
|
|
col.columnName ||
|
|
|
|
|
col.column_name,
|
2026-03-12 04:45:02 +09:00
|
|
|
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,
|
2026-03-12 04:39:57 +09:00
|
|
|
referenceTable: col.referenceTable || col.reference_table,
|
2026-03-12 04:45:02 +09:00
|
|
|
})) as ColumnInfo[];
|
2026-03-12 04:39:57 +09:00
|
|
|
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]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ─── 렌더링 ───
|
2026-03-12 00:27:35 +09:00
|
|
|
return (
|
2026-03-12 04:39:57 +09:00
|
|
|
<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>
|
2026-03-12 08:27:47 +09:00
|
|
|
<span className="text-sm font-medium truncate">좌측 패널 (마스터)</span>
|
|
|
|
|
<p className="text-[10px] text-muted-foreground truncate">
|
2026-03-12 04:39:57 +09:00
|
|
|
{leftTableName || "미설정"}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-03-12 08:27:47 +09:00
|
|
|
<Badge variant="secondary" className="text-[10px] h-5">{config.leftPanel?.columns?.length || 0}개 컬럼</Badge>
|
2026-03-12 04:39:57 +09:00
|
|
|
</div>
|
|
|
|
|
<ChevronDown
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
|
|
|
|
leftPanelOpen && "rotate-180"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
|
|
|
|
</CollapsibleTrigger>
|
2026-03-12 10:04:26 +09:00
|
|
|
<CollapsibleContent>
|
2026-03-12 04:39:57 +09:00
|
|
|
<div className="space-y-4 rounded-b-lg border border-t-0 p-4">
|
|
|
|
|
{/* 좌측 패널 제목 */}
|
|
|
|
|
<div className="space-y-1.5">
|
2026-03-12 08:27:47 +09:00
|
|
|
<Label className="text-xs truncate">패널 제목</Label>
|
2026-03-12 04:39:57 +09:00
|
|
|
<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">
|
2026-03-12 08:27:47 +09:00
|
|
|
<Label className="text-xs truncate">데이터 테이블</Label>
|
2026-03-12 04:39:57 +09:00
|
|
|
<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">
|
2026-03-12 08:27:47 +09:00
|
|
|
<Label className="text-xs truncate">표시 모드</Label>
|
2026-03-12 04:39:57 +09:00
|
|
|
<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}
|
2026-03-12 04:45:02 +09:00
|
|
|
columns={leftTableColumns}
|
2026-03-12 04:39:57 +09:00
|
|
|
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>
|
2026-03-12 08:27:47 +09:00
|
|
|
<span className="text-sm font-medium truncate">
|
2026-03-12 04:39:57 +09:00
|
|
|
우측 패널 (디테일)
|
|
|
|
|
</span>
|
2026-03-12 08:27:47 +09:00
|
|
|
<p className="text-[10px] text-muted-foreground truncate">
|
2026-03-12 04:39:57 +09:00
|
|
|
{rightTableName || "미설정"}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-03-12 08:27:47 +09:00
|
|
|
<Badge variant="secondary" className="text-[10px] h-5">{config.rightPanel?.columns?.length || 0}개 컬럼</Badge>
|
2026-03-12 04:39:57 +09:00
|
|
|
</div>
|
|
|
|
|
<ChevronDown
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
|
|
|
|
rightPanelOpen && "rotate-180"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
|
|
|
|
</CollapsibleTrigger>
|
2026-03-12 10:04:26 +09:00
|
|
|
<CollapsibleContent>
|
2026-03-12 04:39:57 +09:00
|
|
|
<div className="space-y-4 rounded-b-lg border border-t-0 p-4">
|
|
|
|
|
{/* 우측 패널 제목 */}
|
|
|
|
|
<div className="space-y-1.5">
|
2026-03-12 08:27:47 +09:00
|
|
|
<Label className="text-xs truncate">패널 제목</Label>
|
2026-03-12 04:39:57 +09:00
|
|
|
<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">
|
2026-03-12 08:27:47 +09:00
|
|
|
<Label className="text-xs truncate">데이터 테이블</Label>
|
2026-03-12 04:39:57 +09:00
|
|
|
<TableCombobox
|
|
|
|
|
value={rightTableName}
|
|
|
|
|
allTables={allTables}
|
|
|
|
|
screenTableName={screenTableName}
|
|
|
|
|
loading={loadingTables}
|
|
|
|
|
onChange={(tableName) =>
|
|
|
|
|
updateRightPanel({ tableName, columns: [] })
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 표시 모드 */}
|
|
|
|
|
<div className="space-y-1.5">
|
2026-03-12 08:27:47 +09:00
|
|
|
<Label className="text-xs truncate">표시 모드</Label>
|
2026-03-12 04:39:57 +09:00
|
|
|
<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}
|
|
|
|
|
>
|
2026-03-12 04:45:02 +09:00
|
|
|
{col.displayName || col.columnName}
|
2026-03-12 04:39:57 +09:00
|
|
|
</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}
|
|
|
|
|
>
|
2026-03-12 04:45:02 +09:00
|
|
|
{col.displayName || col.columnName}
|
2026-03-12 04:39:57 +09:00
|
|
|
</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}
|
|
|
|
|
>
|
2026-03-12 04:45:02 +09:00
|
|
|
{col.displayName || col.columnName}
|
2026-03-12 04:39:57 +09:00
|
|
|
</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}
|
|
|
|
|
>
|
2026-03-12 04:45:02 +09:00
|
|
|
{col.displayName || col.columnName}
|
2026-03-12 04:39:57 +09:00
|
|
|
</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}
|
2026-03-12 04:45:02 +09:00
|
|
|
columns={rightTableColumns}
|
2026-03-12 04:39:57 +09:00
|
|
|
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}
|
|
|
|
|
>
|
2026-03-12 04:45:02 +09:00
|
|
|
{col.displayName || col.columnName}
|
2026-03-12 04:39:57 +09:00
|
|
|
</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" />
|
2026-03-12 08:27:47 +09:00
|
|
|
<span className="text-sm font-medium truncate">추가 탭</span>
|
|
|
|
|
<Badge variant="secondary" className="text-[10px] h-5">{config.rightPanel?.additionalTabs?.length || 0}개</Badge>
|
2026-03-12 04:39:57 +09:00
|
|
|
</div>
|
|
|
|
|
<ChevronDown
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
|
|
|
|
tabsOpen && "rotate-180"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
|
|
|
|
</CollapsibleTrigger>
|
2026-03-12 10:04:26 +09:00
|
|
|
<CollapsibleContent>
|
2026-03-12 04:39:57 +09:00
|
|
|
<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}
|
|
|
|
|
>
|
2026-03-12 04:45:02 +09:00
|
|
|
{col.displayName || col.columnName}
|
2026-03-12 04:39:57 +09:00
|
|
|
</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}
|
|
|
|
|
>
|
2026-03-12 04:45:02 +09:00
|
|
|
{col.displayName || col.columnName}
|
2026-03-12 04:39:57 +09:00
|
|
|
</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" />
|
2026-03-12 08:27:47 +09:00
|
|
|
<span className="text-sm font-medium truncate">고급 설정</span>
|
|
|
|
|
<Badge variant="secondary" className="text-[10px] h-5">8개</Badge>
|
2026-03-12 04:39:57 +09:00
|
|
|
</div>
|
|
|
|
|
<ChevronDown
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
|
|
|
|
advancedOpen && "rotate-180"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
|
|
|
|
</CollapsibleTrigger>
|
2026-03-12 10:04:26 +09:00
|
|
|
<CollapsibleContent>
|
2026-03-12 04:39:57 +09:00
|
|
|
<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>
|
2026-03-12 00:27:35 +09:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
V2SplitPanelLayoutConfigPanel.displayName = "V2SplitPanelLayoutConfigPanel";
|
|
|
|
|
|
|
|
|
|
export default V2SplitPanelLayoutConfigPanel;
|