ERP-node/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel...

3249 lines
150 KiB
TypeScript

"use client";
import React, { useState, useMemo, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
// Accordion 제거 - 단순 섹션으로 변경
import { Check, ChevronsUpDown, ArrowRight, Plus, X, ArrowUp, ArrowDown, Trash2, Database, GripVertical, Move, Settings2, PanelLeft, PanelRight, Layers, ChevronRight, Link2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { PanelInlineComponent } from "./types";
import { cn } from "@/lib/utils";
import { SplitPanelLayoutConfig, AdditionalTabConfig } from "./types";
import { TableInfo, ColumnInfo } from "@/types/screen";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { tableTypeApi } from "@/lib/api/screen";
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { DndContext, closestCenter, type DragEndEvent, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
// 드래그 가능한 컬럼 아이템
function SortableColumnRow({
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange,
}: {
id: string;
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean };
index: number;
isNumeric: boolean;
isEntityJoin?: boolean;
onLabelChange: (value: string) => void;
onWidthChange: (value: number) => void;
onFormatChange: (checked: boolean) => void;
onRemove: () => void;
onShowInSummaryChange?: (checked: boolean) => void;
onShowInDetailChange?: (checked: boolean) => 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(
"flex items-center gap-1.5 rounded-md border bg-card px-2 py-1.5",
isDragging && "z-50 opacity-50 shadow-md",
isEntityJoin && "border-blue-200 bg-blue-50/30",
)}
>
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
<GripVertical className="h-3 w-3" />
</div>
{isEntityJoin ? (
<Link2 className="h-3 w-3 shrink-0 text-blue-500" title="Entity 조인 컬럼" />
) : (
<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>
)}
{/* 헤더/상세 표시 토글 */}
{onShowInSummaryChange && (
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="테이블 헤더에 표시">
<input
type="checkbox"
checked={col.showInSummary !== false}
onChange={(e) => onShowInSummaryChange(e.target.checked)}
className="h-3 w-3"
/>
</label>
)}
{onShowInDetailChange && (
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="행 클릭 시 상세 정보에 표시">
<input
type="checkbox"
checked={col.showInDetail !== false}
onChange={(e) => onShowInDetailChange(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>
);
}
interface SplitPanelLayoutConfigPanelProps {
config: SplitPanelLayoutConfig;
onChange: (config: SplitPanelLayoutConfig) => void;
tables?: TableInfo[]; // 전체 테이블 목록 (선택적)
screenTableName?: string; // 현재 화면의 테이블명 (좌측 패널에서 사용)
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요)
}
/**
* 그룹핑 기준 컬럼 선택 컴포넌트
*/
const GroupByColumnsSelector: React.FC<{
tableName?: string;
selectedColumns: string[];
onChange: (columns: string[]) => void;
}> = ({ tableName, selectedColumns, onChange }) => {
const [columns, setColumns] = useState<any[]>([]); // ColumnTypeInfo 타입
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!tableName) {
setColumns([]);
return;
}
const loadColumns = async () => {
setLoading(true);
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getColumnList(tableName);
if (response.success && response.data && response.data.columns) {
setColumns(response.data.columns);
}
} catch (error) {
console.error("컬럼 정보 로드 실패:", error);
} finally {
setLoading(false);
}
};
loadColumns();
}, [tableName]);
const toggleColumn = (columnName: string) => {
const newSelection = selectedColumns.includes(columnName)
? selectedColumns.filter((c) => c !== columnName)
: [...selectedColumns, columnName];
onChange(newSelection);
};
if (!tableName) {
return (
<div className="rounded-md border border-dashed p-3">
<p className="text-muted-foreground text-center text-xs"> </p>
</div>
);
}
return (
<div>
<Label className="text-xs"> </Label>
{loading ? (
<div className="rounded-md border p-3">
<p className="text-muted-foreground text-center text-xs"> ...</p>
</div>
) : columns.length === 0 ? (
<div className="rounded-md border border-dashed p-3">
<p className="text-muted-foreground text-center text-xs"> </p>
</div>
) : (
<div className="max-h-[200px] space-y-1 overflow-y-auto rounded-md border p-3">
{columns.map((col) => (
<div key={col.columnName} className="flex items-center gap-2">
<Checkbox
id={`groupby-${col.columnName}`}
checked={selectedColumns.includes(col.columnName)}
onCheckedChange={() => toggleColumn(col.columnName)}
/>
<label htmlFor={`groupby-${col.columnName}`} className="flex-1 cursor-pointer text-xs">
{col.columnLabel || col.columnName}
<span className="text-muted-foreground ml-1">({col.columnName})</span>
</label>
</div>
))}
</div>
)}
<p className="text-muted-foreground mt-1 text-[10px]">
: {selectedColumns.length > 0 ? selectedColumns.join(", ") : "없음"}
<br />
</p>
</div>
);
};
/**
* 화면 선택 Combobox 컴포넌트
*/
const ScreenSelector: React.FC<{
value?: number;
onChange: (screenId?: number) => void;
}> = ({ value, onChange }) => {
const [open, setOpen] = useState(false);
const [screens, setScreens] = useState<Array<{ screenId: number; screenName: string; screenCode: string }>>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const loadScreens = async () => {
setLoading(true);
try {
const { screenApi } = await import("@/lib/api/screen");
const response = await screenApi.getScreens({ page: 1, size: 1000 });
setScreens(
response.data.map((s) => ({ screenId: s.screenId, screenName: s.screenName, screenCode: s.screenCode })),
);
} catch (error) {
console.error("화면 목록 로드 실패:", error);
} finally {
setLoading(false);
}
};
loadScreens();
}, []);
const selectedScreen = screens.find((s) => s.screenId === value);
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}
>
{loading ? "로딩 중..." : selectedScreen ? selectedScreen.screenName : "화면 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="화면 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-6 text-center text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-auto">
{screens.map((screen) => (
<CommandItem
key={screen.screenId}
value={`${screen.screenName.toLowerCase()} ${screen.screenCode.toLowerCase()} ${screen.screenId}`}
onSelect={() => {
onChange(screen.screenId === value ? undefined : screen.screenId);
setOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-4 w-4", value === screen.screenId ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{screen.screenName}</span>
<span className="text-muted-foreground text-[10px]">{screen.screenCode}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
/**
* 추가 탭 설정 패널 (우측 패널과 동일한 구조)
*/
interface AdditionalTabConfigPanelProps {
tab: AdditionalTabConfig;
tabIndex: number;
config: SplitPanelLayoutConfig;
updateRightPanel: (updates: Partial<SplitPanelLayoutConfig["rightPanel"]>) => void;
availableRightTables: TableInfo[];
leftTableColumns: ColumnInfo[];
menuObjid?: number;
// 공유 컬럼 로드 상태
loadedTableColumns: Record<string, ColumnInfo[]>;
loadTableColumns: (tableName: string) => Promise<void>;
loadingColumns: Record<string, boolean>;
// Entity 조인 컬럼 (테이블별)
entityJoinColumns?: 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; description?: string }> }>;
}>;
}
const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
tab,
tabIndex,
config,
updateRightPanel,
availableRightTables,
leftTableColumns,
menuObjid,
loadedTableColumns,
loadTableColumns,
loadingColumns,
entityJoinColumns: entityJoinColumnsMap,
}) => {
// 탭 테이블 변경 시 컬럼 로드
useEffect(() => {
if (tab.tableName && !loadedTableColumns[tab.tableName] && !loadingColumns[tab.tableName]) {
loadTableColumns(tab.tableName);
}
}, [tab.tableName, loadedTableColumns, loadingColumns, loadTableColumns]);
// 현재 탭의 컬럼 목록
const tabColumns = useMemo(() => {
return tab.tableName ? loadedTableColumns[tab.tableName] || [] : [];
}, [tab.tableName, loadedTableColumns]);
// 로딩 상태
const loadingTabColumns = tab.tableName ? loadingColumns[tab.tableName] || false : false;
// 탭 업데이트 헬퍼
const updateTab = (updates: Partial<AdditionalTabConfig>) => {
const newTabs = [...(config.rightPanel?.additionalTabs || [])];
// undefined 값도 명시적으로 덮어쓰기 위해 Object.assign 대신 직접 처리
const updatedTab = { ...tab };
Object.keys(updates).forEach((key) => {
(updatedTab as any)[key] = (updates as any)[key];
});
newTabs[tabIndex] = updatedTab;
updateRightPanel({ additionalTabs: newTabs });
};
return (
<AccordionItem
key={tab.tabId}
value={tab.tabId}
className="rounded-lg border bg-card"
>
<AccordionTrigger className="px-3 py-2 hover:no-underline">
<div className="flex flex-1 items-center gap-2">
<GripVertical className="h-4 w-4 text-gray-400" />
<span className="text-sm font-medium">
{tab.label || `${tabIndex + 1}`}
</span>
{tab.tableName && (
<span className="text-xs text-gray-500">({tab.tableName})</span>
)}
</div>
</AccordionTrigger>
<AccordionContent className="space-y-4 px-3 pb-3">
{/* ===== 1. 기본 정보 ===== */}
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={tab.label}
onChange={(e) => updateTab({ label: e.target.value })}
placeholder="탭 이름"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={tab.title}
onChange={(e) => updateTab({ title: e.target.value })}
placeholder="패널 제목"
className="h-8 text-xs"
/>
</div>
</div>
</div>
{/* ===== 2. 테이블 선택 ===== */}
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
{tab.tableName || "테이블을 선택하세요"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{availableRightTables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName || ""} ${table.tableName}`}
onSelect={() => updateTab({ tableName: table.tableName, columns: [] })}
>
<Check
className={cn(
"mr-2 h-4 w-4",
tab.tableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
{table.displayName || table.tableName}
{table.displayName && <span className="ml-2 text-xs text-gray-500">({table.tableName})</span>}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
{/* ===== 3. 표시 모드 + 요약 설정 ===== */}
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={tab.displayMode || "list"}
onValueChange={(value: "list" | "table") => updateTab({ displayMode: value })}
>
<SelectTrigger className="h-8 bg-white text-xs">
<SelectValue>
{(tab.displayMode || "list") === "list" ? "목록 (LIST)" : "테이블 (TABLE)"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="list">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> (LIST)</span>
<span className="text-xs text-gray-500"> ()</span>
</div>
</SelectItem>
<SelectItem value="table">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> (TABLE)</span>
<span className="text-xs text-gray-500"> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 요약 설정 (목록 모드) */}
{(tab.displayMode || "list") === "list" && (
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
<Label className="text-sm font-semibold"> </Label>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
type="number" min="1" max="10"
value={tab.summaryColumnCount ?? 3}
onChange={(e) => updateTab({ summaryColumnCount: parseInt(e.target.value) || 3 })}
className="bg-white"
/>
<p className="text-xs text-gray-500"> (기본: 3개)</p>
</div>
<div className="flex items-center justify-between space-x-2">
<div className="flex-1">
<Label className="text-xs"> </Label>
<p className="text-xs text-gray-500"> </p>
</div>
<Checkbox
checked={tab.summaryShowLabel ?? true}
onCheckedChange={(checked) => updateTab({ summaryShowLabel: checked as boolean })}
/>
</div>
</div>
)}
</div>
{/* ===== 4. 컬럼 매핑 (연결 키) ===== */}
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> ( )</h3>
<p className="text-muted-foreground text-[10px]"> </p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={tab.relation?.keys?.[0]?.leftColumn || tab.relation?.leftColumn || "__none__"}
onValueChange={(value) => {
if (value === "__none__") {
updateTab({ relation: undefined });
} else {
updateTab({
relation: {
...tab.relation,
type: "join",
keys: [{ leftColumn: value, rightColumn: tab.relation?.keys?.[0]?.rightColumn || "" }],
},
});
}
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"><span className="text-muted-foreground"> ( )</span></SelectItem>
{leftTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>{col.columnLabel || col.columnName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={tab.relation?.keys?.[0]?.rightColumn || tab.relation?.foreignKey || "__none__"}
onValueChange={(value) => {
if (value === "__none__") {
updateTab({ relation: undefined });
} else {
updateTab({
relation: {
...tab.relation,
type: "join",
keys: [{ leftColumn: tab.relation?.keys?.[0]?.leftColumn || "", rightColumn: value }],
},
});
}
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"><span className="text-muted-foreground"> ( )</span></SelectItem>
{tabColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>{col.columnLabel || col.columnName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* ===== 5. 기능 버튼 ===== */}
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<div className="grid grid-cols-4 gap-2">
<div className="flex items-center gap-1">
<Checkbox id={`tab-${tabIndex}-search`} checked={tab.showSearch} onCheckedChange={(checked) => updateTab({ showSearch: !!checked })} />
<label htmlFor={`tab-${tabIndex}-search`} className="text-xs"></label>
</div>
<div className="flex items-center gap-1">
<Checkbox id={`tab-${tabIndex}-add`} checked={tab.showAdd} onCheckedChange={(checked) => updateTab({ showAdd: !!checked })} />
<label htmlFor={`tab-${tabIndex}-add`} className="text-xs"></label>
</div>
<div className="flex items-center gap-1">
<Checkbox id={`tab-${tabIndex}-edit`} checked={tab.showEdit} onCheckedChange={(checked) => updateTab({ showEdit: !!checked })} />
<label htmlFor={`tab-${tabIndex}-edit`} className="text-xs"></label>
</div>
<div className="flex items-center gap-1">
<Checkbox id={`tab-${tabIndex}-delete`} checked={tab.showDelete} onCheckedChange={(checked) => updateTab({ showDelete: !!checked })} />
<label htmlFor={`tab-${tabIndex}-delete`} className="text-xs"></label>
</div>
</div>
</div>
{/* ===== 6. 표시할 컬럼 - DnD + Entity 조인 통합 ===== */}
{(() => {
const selectedColumns = tab.columns || [];
const filteredTabCols = tabColumns.filter((c) => !["company_code", "company_name"].includes(c.columnName));
const unselectedCols = filteredTabCols.filter((c) => !selectedColumns.some((sc) => sc.name === c.columnName));
const dbNumericTypes = ["numeric", "decimal", "integer", "bigint", "double precision", "real", "smallint", "int4", "int8", "float4", "float8"];
const inputNumericTypes = ["number", "decimal", "currency", "integer"];
const handleTabDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = selectedColumns.findIndex((c) => c.name === active.id);
const newIndex = selectedColumns.findIndex((c) => c.name === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
updateTab({ columns: arrayMove([...selectedColumns], oldIndex, newIndex) });
}
}
};
return (
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> ({selectedColumns.length} )</h3>
<div className="max-h-[400px] overflow-y-auto rounded-md border bg-white p-2">
{!tab.tableName ? (
<p className="text-muted-foreground py-2 text-center text-xs"> </p>
) : loadingTabColumns ? (
<p className="text-muted-foreground py-2 text-center text-xs"> ...</p>
) : (
<>
{selectedColumns.length > 0 && (
<DndContext collisionDetection={closestCenter} onDragEnd={handleTabDragEnd}>
<SortableContext items={selectedColumns.map((c) => c.name)} strategy={verticalListSortingStrategy}>
<div className="space-y-1">
{selectedColumns.map((col, index) => {
const colInfo = tabColumns.find((c) => c.columnName === col.name);
const isNumeric = colInfo && (
dbNumericTypes.includes(colInfo.dataType?.toLowerCase() || "") ||
inputNumericTypes.includes(colInfo.input_type?.toLowerCase() || "") ||
inputNumericTypes.includes(colInfo.webType?.toLowerCase() || "")
);
return (
<SortableColumnRow
key={col.name}
id={col.name}
col={col}
index={index}
isNumeric={!!isNumeric}
isEntityJoin={!!(col as any).isEntityJoin}
onLabelChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], label: value };
updateTab({ columns: newColumns });
}}
onWidthChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], width: value };
updateTab({ columns: newColumns });
}}
onFormatChange={(checked) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
updateTab({ columns: newColumns });
}}
onRemove={() => updateTab({ columns: selectedColumns.filter((_, i) => i !== index) })}
onShowInSummaryChange={(checked) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], showInSummary: checked };
updateTab({ columns: newColumns });
}}
onShowInDetailChange={(checked) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], showInDetail: checked };
updateTab({ columns: newColumns });
}}
/>
);
})}
</div>
</SortableContext>
</DndContext>
)}
{selectedColumns.length > 0 && unselectedCols.length > 0 && (
<div className="border-border/60 my-2 flex items-center gap-2 border-t pt-2">
<span className="text-muted-foreground text-[10px]"> </span>
</div>
)}
<div className="space-y-0.5">
{unselectedCols.map((column) => (
<div
key={column.columnName}
className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1.5"
onClick={() => {
updateTab({ columns: [...selectedColumns, { name: column.columnName, label: column.columnLabel || column.columnName, width: 100 }] });
}}
>
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
<span className="text-muted-foreground truncate text-xs">{column.columnLabel || column.columnName}</span>
</div>
))}
</div>
{/* Entity 조인 컬럼 - 아코디언 (접기/펼치기) */}
{(() => {
const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null;
if (!joinData || joinData.joinTables.length === 0) return null;
return joinData.joinTables.map((joinTable, tableIndex) => {
const joinColumnsToShow = joinTable.availableColumns.filter((column) => {
const matchingJoinColumn = joinData.availableColumns.find(
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
);
if (!matchingJoinColumn) return false;
return !selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias);
});
const addedCount = joinTable.availableColumns.length - joinColumnsToShow.length;
if (joinColumnsToShow.length === 0 && addedCount === 0) return null;
return (
<details key={`tab-join-${tableIndex}`} className="group">
<summary className="border-border/60 my-2 flex cursor-pointer list-none items-center gap-2 border-t pt-2 select-none">
<ChevronRight className="h-3 w-3 shrink-0 text-blue-500 transition-transform group-open:rotate-90" />
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
<span className="text-[10px] font-medium text-blue-600">{joinTable.tableName}</span>
{addedCount > 0 && (
<span className="rounded-full bg-blue-100 px-1.5 text-[9px] font-medium text-blue-600">{addedCount} </span>
)}
<span className="text-[9px] text-gray-400">{joinColumnsToShow.length} </span>
</summary>
<div className="space-y-0.5 pt-1">
{joinColumnsToShow.map((column, colIndex) => {
const matchingJoinColumn = joinData.availableColumns.find(
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
);
if (!matchingJoinColumn) return null;
return (
<div
key={colIndex}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-blue-50/60"
onClick={() => {
updateTab({
columns: [...selectedColumns, {
name: matchingJoinColumn.joinAlias,
label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel,
width: 100,
isEntityJoin: true,
joinInfo: {
sourceTable: tab.tableName!,
sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "",
referenceTable: matchingJoinColumn.tableName,
joinAlias: matchingJoinColumn.joinAlias,
},
}],
});
}}
>
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
<span className="truncate text-xs text-blue-700">{column.columnLabel || column.columnName}</span>
</div>
);
})}
{joinColumnsToShow.length === 0 && (
<p className="px-2 py-1 text-[10px] text-gray-400"> </p>
)}
</div>
</details>
);
});
})()}
</>
)}
</div>
</div>
);
})()}
{/* ===== 7. 추가 모달 컬럼 설정 (showAdd일 때) ===== */}
{tab.showAdd && (
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<div className="flex items-center justify-between">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<Button
size="sm"
variant="outline"
onClick={() => {
const currentColumns = tab.addModalColumns || [];
const newColumns = [...currentColumns, { name: "", label: "", required: false }];
updateTab({ addModalColumns: newColumns });
}}
className="h-7 text-xs"
disabled={!tab.tableName}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
{(tab.addModalColumns || []).length === 0 ? (
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
<p className="text-xs text-gray-500"> </p>
</div>
) : (
(tab.addModalColumns || []).map((col, colIndex) => (
<div key={colIndex} className="flex items-center gap-2 rounded-md border bg-white p-2">
<Select
value={col.name}
onValueChange={(value) => {
const selectedCol = tabColumns.find((c) => c.columnName === value);
const newColumns = [...(tab.addModalColumns || [])];
newColumns[colIndex] = {
...col,
name: value,
label: selectedCol?.columnLabel || value,
};
updateTab({ addModalColumns: newColumns });
}}
>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tabColumns.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={col.label}
onChange={(e) => {
const newColumns = [...(tab.addModalColumns || [])];
newColumns[colIndex] = { ...col, label: e.target.value };
updateTab({ addModalColumns: newColumns });
}}
placeholder="라벨"
className="h-8 w-24 text-xs"
/>
<div className="flex items-center gap-1">
<Checkbox
checked={col.required}
onCheckedChange={(checked) => {
const newColumns = [...(tab.addModalColumns || [])];
newColumns[colIndex] = { ...col, required: !!checked };
updateTab({ addModalColumns: newColumns });
}}
/>
<span className="text-[10px]"></span>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newColumns = (tab.addModalColumns || []).filter((_, i) => i !== colIndex);
updateTab({ addModalColumns: newColumns });
}}
className="h-8 w-8 p-0 text-red-500"
>
<X className="h-3 w-3" />
</Button>
</div>
))
)}
</div>
</div>
)}
{/* Entity 조인 컬럼은 표시 컬럼 목록에 통합됨 */}
{/* ===== 8. 데이터 필터링 ===== */}
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<DataFilterConfigPanel
tableName={tab.tableName}
columns={tabColumns}
config={tab.dataFilter}
onConfigChange={(dataFilter) => updateTab({ dataFilter })}
menuObjid={menuObjid}
/>
</div>
{/* ===== 9. 중복 데이터 제거 ===== */}
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<div className="flex items-center justify-between">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<Switch
checked={tab.deduplication?.enabled ?? false}
onCheckedChange={(checked) => {
if (checked) {
updateTab({
deduplication: {
enabled: true,
groupByColumn: "",
keepStrategy: "latest",
sortColumn: "start_date",
},
});
} else {
updateTab({ deduplication: undefined });
}
}}
/>
</div>
{tab.deduplication?.enabled && (
<div className="mt-2 space-y-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={tab.deduplication?.groupByColumn || ""}
onValueChange={(value) => {
updateTab({
deduplication: { ...tab.deduplication!, groupByColumn: value },
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tabColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={tab.deduplication?.sortColumn || ""}
onValueChange={(value) => {
updateTab({
deduplication: { ...tab.deduplication!, sortColumn: value },
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tabColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={tab.deduplication?.keepStrategy || "latest"}
onValueChange={(value: "latest" | "earliest" | "base_price" | "current_date") => {
updateTab({
deduplication: { ...tab.deduplication!, keepStrategy: value },
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="latest"></SelectItem>
<SelectItem value="earliest"> </SelectItem>
<SelectItem value="current_date"> </SelectItem>
<SelectItem value="base_price"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
{/* ===== 10. 수정 버튼 설정 ===== */}
{tab.showEdit && (
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={tab.editButton?.mode || "auto"}
onValueChange={(value: "auto" | "modal") => {
updateTab({
editButton: { ...tab.editButton, enabled: true, mode: value },
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ()</SelectItem>
<SelectItem value="modal"> </SelectItem>
</SelectContent>
</Select>
</div>
{tab.editButton?.mode === "modal" && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<ScreenSelector
value={tab.editButton?.modalScreenId}
onChange={(screenId) => {
updateTab({
editButton: { ...tab.editButton, enabled: true, mode: "modal", modalScreenId: screenId },
});
}}
/>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={tab.editButton?.buttonLabel || ""}
onChange={(e) => {
updateTab({
editButton: { ...tab.editButton, enabled: true, buttonLabel: e.target.value || undefined },
});
}}
placeholder="수정"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={tab.editButton?.buttonVariant || "ghost"}
onValueChange={(value: "default" | "outline" | "ghost") => {
updateTab({
editButton: { ...tab.editButton, enabled: true, buttonVariant: value },
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ghost">Ghost ()</SelectItem>
<SelectItem value="outline">Outline</SelectItem>
<SelectItem value="default">Default</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 그룹핑 기준 컬럼 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<p className="text-[9px] text-gray-500"> </p>
<div className="max-h-[120px] space-y-1 overflow-y-auto rounded-md border bg-white p-2">
{tabColumns.map((col) => (
<div key={col.columnName} className="flex items-center gap-2">
<Checkbox
id={`tab-${tabIndex}-groupby-${col.columnName}`}
checked={(tab.editButton?.groupByColumns || []).includes(col.columnName)}
onCheckedChange={(checked) => {
const current = tab.editButton?.groupByColumns || [];
const newColumns = checked
? [...current, col.columnName]
: current.filter((c) => c !== col.columnName);
updateTab({
editButton: { ...tab.editButton, enabled: true, groupByColumns: newColumns },
});
}}
/>
<label htmlFor={`tab-${tabIndex}-groupby-${col.columnName}`} className="text-[10px]">
{col.columnLabel || col.columnName}
</label>
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* ===== 10-1. 추가 버튼 설정 ===== */}
{tab.showAdd && (
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={tab.addButton?.mode || "auto"}
onValueChange={(value: "auto" | "modal") => {
updateTab({
addButton: { ...tab.addButton, enabled: true, mode: value },
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ( )</SelectItem>
<SelectItem value="modal"> </SelectItem>
</SelectContent>
</Select>
</div>
{tab.addButton?.mode === "modal" && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<ScreenSelector
value={tab.addButton?.modalScreenId}
onChange={(screenId) => {
updateTab({
addButton: { ...tab.addButton, enabled: true, mode: "modal", modalScreenId: screenId },
});
}}
/>
</div>
)}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={tab.addButton?.buttonLabel || ""}
onChange={(e) => {
updateTab({
addButton: { ...tab.addButton, enabled: true, buttonLabel: e.target.value || undefined },
});
}}
placeholder="추가"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
{/* ===== 11. 삭제 버튼 설정 ===== */}
{tab.showDelete && (
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={tab.deleteButton?.buttonLabel || ""}
onChange={(e) => {
updateTab({
deleteButton: { ...tab.deleteButton, enabled: true, buttonLabel: e.target.value || undefined },
});
}}
placeholder="삭제"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={tab.deleteButton?.buttonVariant || "ghost"}
onValueChange={(value: "default" | "outline" | "ghost" | "destructive") => {
updateTab({
deleteButton: { ...tab.deleteButton, enabled: true, buttonVariant: value },
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ghost">Ghost ()</SelectItem>
<SelectItem value="outline">Outline</SelectItem>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="destructive">Destructive ()</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={tab.deleteButton?.confirmMessage || ""}
onChange={(e) => {
updateTab({
deleteButton: { ...tab.deleteButton, enabled: true, confirmMessage: e.target.value || undefined },
});
}}
placeholder="정말 삭제하시겠습니까?"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
{/* ===== 탭 삭제 버튼 ===== */}
<div className="flex justify-end border-t pt-2">
<Button
variant="ghost"
size="sm"
className="text-red-500 hover:bg-red-50 hover:text-red-600"
onClick={() => {
const newTabs = config.rightPanel?.additionalTabs?.filter((_, i) => i !== tabIndex) || [];
updateRightPanel({ additionalTabs: newTabs });
}}
>
<Trash2 className="mr-1 h-3 w-3" />
</Button>
</div>
</AccordionContent>
</AccordionItem>
);
};
/**
* SplitPanelLayout 설정 패널
*/
export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelProps> = ({
config,
onChange,
tables = [], // 기본값 빈 배열 (현재 화면 테이블만)
screenTableName, // 현재 화면의 테이블명
menuObjid, // 🆕 메뉴 OBJID
}) => {
const [activeModal, setActiveModal] = useState<string | null>(null); // 설정 모달 상태
const [leftTableOpen, setLeftTableOpen] = useState(false); // 🆕 좌측 테이블 Combobox 상태
const [rightTableOpen, setRightTableOpen] = useState(false);
const [loadedTableColumns, setLoadedTableColumns] = useState<Record<string, ColumnInfo[]>>({});
const [loadingColumns, setLoadingColumns] = useState<Record<string, boolean>>({});
const [allTables, setAllTables] = useState<any[]>([]); // 테이블 목록
// 엔티티 참조 테이블 컬럼
type EntityRefTable = { tableName: string; columns: ColumnInfo[] };
const [entityReferenceTables, setEntityReferenceTables] = useState<Record<string, EntityRefTable[]>>({});
// Entity 조인 컬럼 (테이블별)
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;
description?: string;
}>;
}>;
}
>
>({});
const [loadingEntityJoins, setLoadingEntityJoins] = useState<Record<string, boolean>>({});
// 🆕 입력 필드용 로컬 상태
const [isUserEditing, setIsUserEditing] = useState(false);
const [localTitles, setLocalTitles] = useState({
left: config.leftPanel?.title || "",
right: config.rightPanel?.title || "",
});
// 관계 타입
const relationshipType = config.rightPanel?.relation?.type || "detail";
// config 변경 시 로컬 타이틀 동기화 (사용자가 입력 중이 아닐 때만)
useEffect(() => {
if (!isUserEditing) {
setLocalTitles({
left: config.leftPanel?.title || "",
right: config.rightPanel?.title || "",
});
}
}, [config.leftPanel?.title, config.rightPanel?.title, isUserEditing]);
// 전체 테이블 목록 항상 로드 (좌측/우측 모두 사용)
useEffect(() => {
const loadAllTables = async () => {
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
console.log("✅ 분할패널: 전체 테이블 목록 로드", response.data.length, "개");
setAllTables(response.data);
}
} catch (error) {
console.error("❌ 전체 테이블 목록 로드 실패:", error);
}
};
loadAllTables();
}, []);
// 초기 로드 시 좌측 패널 테이블이 없으면 화면 테이블로 설정
useEffect(() => {
if (screenTableName && !config.leftPanel?.tableName) {
updateLeftPanel({ tableName: screenTableName });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [screenTableName]);
// 테이블 컬럼 로드 함수
const loadTableColumns = async (tableName: string) => {
if (loadedTableColumns[tableName] || loadingColumns[tableName]) {
return; // 이미 로드되었거나 로딩 중
}
setLoadingColumns((prev) => ({ ...prev, [tableName]: true }));
try {
const columnsResponse = await tableTypeApi.getColumns(tableName);
console.log(`📊 테이블 ${tableName} 컬럼 응답:`, columnsResponse);
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => ({
tableName: col.tableName || tableName,
columnName: col.columnName || col.column_name,
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type || col.dbType,
webType: col.webType || col.web_type,
input_type: col.inputType || col.input_type,
widgetType: col.widgetType || col.widget_type || col.webType || col.web_type,
isNullable: col.isNullable || col.is_nullable,
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
columnDefault: col.columnDefault || col.column_default,
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
isPrimaryKey: col.isPrimaryKey || false, // PK 여부 추가
codeCategory: col.codeCategory || col.code_category,
codeValue: col.codeValue || col.code_value,
referenceTable: col.referenceTable || col.reference_table, // 🆕 참조 테이블
referenceColumn: col.referenceColumn || col.reference_column, // 🆕 참조 컬럼
displayColumn: col.displayColumn || col.display_column, // 🆕 표시 컬럼
}));
console.log(`✅ 테이블 ${tableName} 컬럼 ${columns.length}개 로드됨:`, columns);
setLoadedTableColumns((prev) => ({ ...prev, [tableName]: columns }));
// 🆕 엔티티 타입 컬럼의 참조 테이블 컬럼도 로드
await loadEntityReferenceColumns(tableName, columns);
// Entity 조인 컬럼 정보도 로드
await loadEntityJoinColumns(tableName);
} catch (error) {
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
setLoadedTableColumns((prev) => ({ ...prev, [tableName]: [] }));
} finally {
setLoadingColumns((prev) => ({ ...prev, [tableName]: false }));
}
};
// Entity 조인 컬럼 로드
const loadEntityJoinColumns = async (tableName: string) => {
if (entityJoinColumns[tableName] || loadingEntityJoins[tableName]) return;
setLoadingEntityJoins((prev) => ({ ...prev, [tableName]: true }));
try {
const result = await entityJoinApi.getEntityJoinColumns(tableName);
console.log(`🔗 Entity 조인 컬럼 (${tableName}):`, result);
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 }));
}
};
// 🆕 엔티티 참조 테이블의 컬럼 로드
const loadEntityReferenceColumns = async (sourceTableName: string, columns: ColumnInfo[]) => {
const entityColumns = columns.filter(
(col) => (col.input_type === "entity" || col.webType === "entity") && col.referenceTable,
);
if (entityColumns.length === 0) {
return;
}
console.log(
`🔗 테이블 ${sourceTableName}의 엔티티 참조 ${entityColumns.length}개 발견:`,
entityColumns.map((c) => `${c.columnName} -> ${c.referenceTable}`),
);
const referenceTableData: Array<{ tableName: string; columns: ColumnInfo[] }> = [];
// 각 참조 테이블의 컬럼 로드
for (const entityCol of entityColumns) {
const refTableName = entityCol.referenceTable!;
// 이미 로드했으면 스킵
if (referenceTableData.some((t) => t.tableName === refTableName)) continue;
try {
const refColumnsResponse = await tableTypeApi.getColumns(refTableName);
const refColumns: ColumnInfo[] = (refColumnsResponse || []).map((col: any) => ({
tableName: col.tableName || refTableName,
columnName: col.columnName || col.column_name,
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type || col.dbType,
input_type: col.inputType || col.input_type,
}));
referenceTableData.push({ tableName: refTableName, columns: refColumns });
console.log(` ✅ 참조 테이블 ${refTableName} 컬럼 ${refColumns.length}개 로드됨`);
} catch (error) {
console.error(` ❌ 참조 테이블 ${refTableName} 컬럼 로드 실패:`, error);
}
}
// 참조 테이블 정보 저장
setEntityReferenceTables((prev) => ({
...prev,
[sourceTableName]: referenceTableData,
}));
console.log(`✅ [엔티티 참조] ${sourceTableName}의 참조 테이블 저장 완료:`, {
sourceTableName,
referenceTableCount: referenceTableData.length,
referenceTables: referenceTableData.map((t) => `${t.tableName}(${t.columns.length}개)`),
});
};
// 좌측/우측 테이블이 변경되면 해당 테이블의 컬럼 로드
useEffect(() => {
if (config.leftPanel?.tableName) {
loadTableColumns(config.leftPanel.tableName);
}
}, [config.leftPanel?.tableName]);
useEffect(() => {
if (config.rightPanel?.tableName) {
loadTableColumns(config.rightPanel.tableName);
}
}, [config.rightPanel?.tableName]);
// 🆕 좌측/우측 테이블이 모두 선택되면 엔티티 관계 자동 감지
const [autoDetectedRelations, setAutoDetectedRelations] = useState<
Array<{
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
}>
>([]);
const [isDetectingRelations, setIsDetectingRelations] = useState(false);
useEffect(() => {
const detectRelations = async () => {
const leftTable = config.leftPanel?.tableName || screenTableName;
const rightTable = config.rightPanel?.tableName;
// 조인 모드이고 양쪽 테이블이 모두 있을 때만 감지
if (relationshipType !== "join" || !leftTable || !rightTable) {
setAutoDetectedRelations([]);
return;
}
setIsDetectingRelations(true);
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getTableEntityRelations(leftTable, rightTable);
if (response.success && response.data?.relations) {
console.log("🔍 엔티티 관계 자동 감지:", response.data.relations);
setAutoDetectedRelations(response.data.relations);
// 감지된 관계가 있고, 현재 설정된 키가 없으면 자동으로 첫 번째 관계를 설정
const currentKeys = config.rightPanel?.relation?.keys || [];
if (response.data.relations.length > 0 && currentKeys.length === 0) {
// 첫 번째 관계만 자동 설정 (사용자가 추가로 설정 가능)
const firstRel = response.data.relations[0];
console.log("✅ 첫 번째 엔티티 관계 자동 설정:", firstRel);
updateRightPanel({
relation: {
...config.rightPanel?.relation,
type: "join",
useMultipleKeys: true,
keys: [
{
leftColumn: firstRel.leftColumn,
rightColumn: firstRel.rightColumn,
},
],
},
});
}
}
} catch (error) {
console.error("❌ 엔티티 관계 감지 실패:", error);
setAutoDetectedRelations([]);
} finally {
setIsDetectingRelations(false);
}
};
detectRelations();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.leftPanel?.tableName, config.rightPanel?.tableName, screenTableName, relationshipType]);
console.log("🔧 SplitPanelLayoutConfigPanel 렌더링");
console.log(" - config:", config);
console.log(" - tables:", tables);
console.log(" - tablesCount:", tables.length);
console.log(" - screenTableName:", screenTableName);
console.log(" - leftTable:", config.leftPanel?.tableName);
console.log(" - rightTable:", config.rightPanel?.tableName);
const updateConfig = (updates: Partial<SplitPanelLayoutConfig>) => {
const newConfig = { ...config, ...updates };
console.log("🔄 Config 업데이트:", newConfig);
onChange(newConfig);
};
const updateLeftPanel = (updates: Partial<SplitPanelLayoutConfig["leftPanel"]>) => {
const newConfig = {
...config,
leftPanel: { ...config.leftPanel, ...updates },
};
console.log("🔄 Left Panel 업데이트:", newConfig);
onChange(newConfig);
};
const updateRightPanel = (updates: Partial<SplitPanelLayoutConfig["rightPanel"]>) => {
const newConfig = {
...config,
rightPanel: { ...config.rightPanel, ...updates },
};
console.log("🔄 Right Panel 업데이트:", newConfig);
onChange(newConfig);
};
// 좌측 테이블명
const leftTableName = config.leftPanel?.tableName || screenTableName || "";
// 좌측 테이블 컬럼 (로드된 컬럼 사용)
const leftTableColumns = useMemo(() => {
return leftTableName ? loadedTableColumns[leftTableName] || [] : [];
}, [loadedTableColumns, leftTableName]);
// 우측 테이블명 (상세 모드에서는 좌측과 동일)
const rightTableName = useMemo(() => {
if (relationshipType === "detail") {
return leftTableName; // 상세 모드에서는 좌측과 동일
}
return config.rightPanel?.tableName || "";
}, [relationshipType, leftTableName, config.rightPanel?.tableName]);
// 우측 테이블 컬럼 (로드된 컬럼 사용)
const rightTableColumns = useMemo(() => {
return rightTableName ? loadedTableColumns[rightTableName] || [] : [];
}, [loadedTableColumns, rightTableName]);
// 테이블 데이터 로딩 상태 확인
if (!tables || tables.length === 0) {
return (
<div className="rounded-lg border p-4">
<p className="text-sm font-medium"> .</p>
<p className="mt-1 text-xs text-gray-600">
.
</p>
</div>
);
}
// 조인 모드에서 우측 테이블 선택 시 사용할 테이블 목록
const availableRightTables = relationshipType === "join" ? allTables : tables;
console.log("📊 분할패널 테이블 목록 상태:");
console.log(" - relationshipType:", relationshipType);
console.log(" - allTables:", allTables.length, "개");
console.log(" - availableRightTables:", availableRightTables.length, "개");
return (
<div className="space-y-3">
{/* ===== 간소화된 설정 메뉴 카드 ===== */}
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> </h3>
<div className="space-y-1.5">
{[
{
id: "basic",
title: "기본 설정",
desc: `${relationshipType === "detail" ? "상세" : "조건 필터"} | 비율 ${config.splitRatio || 30}%`,
icon: Settings2,
},
{
id: "left",
title: "좌측 패널",
desc: config.leftPanel?.tableName || screenTableName || "미설정",
icon: PanelLeft,
},
{
id: "right",
title: "우측 패널",
desc: config.rightPanel?.tableName || "미설정",
icon: PanelRight,
},
{
id: "tabs",
title: "추가 탭",
desc: `${config.rightPanel?.additionalTabs?.length || 0}개 탭`,
icon: Layers,
},
].map((item) => (
<button
key={item.id}
onClick={() => setActiveModal(item.id)}
className="flex w-full items-center gap-3 rounded-lg border bg-card p-3 text-left transition-colors hover:bg-accent"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted">
<item.icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{item.title}</p>
<p className="truncate text-xs text-muted-foreground">{item.desc}</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
))}
</div>
{/* ===== 기본 설정 모달 ===== */}
<Dialog open={activeModal === "basic"} onOpenChange={(open) => !open && setActiveModal(null)}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base"> </DialogTitle>
<DialogDescription className="text-xs"> </DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* 관계 타입 선택 */}
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
<Select
value={relationshipType}
onValueChange={(value: "join" | "detail") => {
// 상세 모드로 변경 시 우측 테이블을 현재 화면 테이블로 설정
if (value === "detail" && screenTableName) {
updateRightPanel({
relation: { ...config.rightPanel?.relation, type: value },
tableName: screenTableName,
});
} else {
updateRightPanel({
relation: { ...config.rightPanel?.relation, type: value },
});
}
}}
>
<SelectTrigger className="h-10 bg-white">
<SelectValue placeholder="관계 타입 선택">
{relationshipType === "detail" ? "상세 (DETAIL)" : "조건 필터 (FILTERED)"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="detail">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> (DETAIL)</span>
<span className="text-xs text-gray-500"> ( )</span>
</div>
</SelectItem>
<SelectItem value="join">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> (FILTERED)</span>
<span className="text-xs text-gray-500"> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 레이아웃 설정 */}
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"></h3>
<div className="space-y-2">
<Label> : {config.splitRatio || 30}%</Label>
<Slider
value={[config.splitRatio || 30]}
onValueChange={(value) => updateConfig({ splitRatio: value[0] })}
min={20}
max={80}
step={5}
/>
</div>
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={config.resizable ?? true}
onCheckedChange={(checked) => updateConfig({ resizable: checked })}
/>
</div>
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={config.autoLoad ?? true}
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
/>
</div>
</div>
</div>
</DialogContent>
</Dialog>
{/* ===== 좌측 패널 모달 ===== */}
<Dialog open={activeModal === "left"} onOpenChange={(open) => !open && setActiveModal(null)}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base"> </DialogTitle>
<DialogDescription className="text-xs"> </DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 좌측 패널 설정 */}
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> ()</h3>
{/* 🆕 좌측 패널 테이블 선택 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Popover open={leftTableOpen} onOpenChange={setLeftTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={leftTableOpen}
className="h-9 w-full justify-between text-xs"
>
<div className="flex items-center gap-2 truncate">
<Database className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">
{config.leftPanel?.tableName
? allTables.find((t) => (t.tableName || t.table_name) === config.leftPanel?.tableName)?.tableLabel ||
allTables.find((t) => (t.tableName || t.table_name) === config.leftPanel?.tableName)?.displayName ||
config.leftPanel?.tableName
: "테이블을 선택하세요"}
</span>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-3 text-center text-xs text-muted-foreground">
.
</CommandEmpty>
{/* 화면 기본 테이블 */}
{screenTableName && (
<CommandGroup heading="기본 (화면 테이블)">
<CommandItem
value={screenTableName}
onSelect={() => {
updateLeftPanel({ tableName: screenTableName, columns: [] });
setLeftTableOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.leftPanel?.tableName === screenTableName ? "opacity-100" : "opacity-0"
)}
/>
<Database className="mr-2 h-3.5 w-3.5 text-blue-500" />
{allTables.find((t) => (t.tableName || t.table_name) === screenTableName)?.tableLabel ||
allTables.find((t) => (t.tableName || t.table_name) === screenTableName)?.displayName ||
screenTableName}
</CommandItem>
</CommandGroup>
)}
{/* 전체 테이블 */}
<CommandGroup heading="전체 테이블">
{allTables
.filter((t) => (t.tableName || t.table_name) !== screenTableName)
.map((table) => {
const tableName = table.tableName || table.table_name;
const displayName = table.tableLabel || table.displayName || tableName;
return (
<CommandItem
key={tableName}
value={tableName}
onSelect={() => {
updateLeftPanel({ tableName, columns: [] });
setLeftTableOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.leftPanel?.tableName === tableName ? "opacity-100" : "opacity-0"
)}
/>
<Database className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate">{displayName}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{config.leftPanel?.tableName && config.leftPanel?.tableName !== screenTableName && (
<p className="text-[10px] text-muted-foreground">
.
</p>
)}
</div>
<div className="space-y-2">
<Label> </Label>
<Input
value={localTitles.left}
onChange={(e) => {
setIsUserEditing(true);
setLocalTitles((prev) => ({ ...prev, left: e.target.value }));
}}
onBlur={() => {
setIsUserEditing(false);
updateLeftPanel({ title: localTitles.left });
}}
placeholder="좌측 패널 제목"
/>
</div>
{/* 헤더 높이 설정 - 숨김 처리 */}
{/* <div className="space-y-2">
<Label>헤더 높이 (px)</Label>
<Input
type="number"
value={config.leftPanel?.panelHeaderHeight || 48}
onChange={(e) => updateLeftPanel({ panelHeaderHeight: parseInt(e.target.value) || 48 })}
placeholder="48"
min={32}
max={120}
/>
<p className="text-muted-foreground text-xs">패널 상단 헤더의 높이 (기본: 48px)</p>
</div> */}
<div className="space-y-2">
<Label> </Label>
<Select
value={config.leftPanel?.displayMode || "list"}
onValueChange={(value: "list" | "table" | "custom") => updateLeftPanel({ displayMode: value })}
>
<SelectTrigger className="h-10 bg-white">
<SelectValue placeholder="표시 모드 선택">
{(config.leftPanel?.displayMode || "list") === "list" ? "목록 (LIST)" : (config.leftPanel?.displayMode || "list") === "table" ? "테이블 (TABLE)" : "커스텀 (CUSTOM)"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="list">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> (LIST)</span>
<span className="text-xs text-gray-500"> ()</span>
</div>
</SelectItem>
<SelectItem value="table">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> (TABLE)</span>
<span className="text-xs text-gray-500"> </span>
</div>
</SelectItem>
<SelectItem value="custom">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> (CUSTOM)</span>
<span className="text-xs text-gray-500"> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
{config.leftPanel?.displayMode === "custom" && (
<p className="text-xs text-amber-600">
.
</p>
)}
</div>
{/* 🆕 커스텀 모드: 배치된 컴포넌트 목록 */}
{config.leftPanel?.displayMode === "custom" && (
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
{!config.leftPanel?.components || config.leftPanel.components.length === 0 ? (
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
<Move className="mx-auto mb-2 h-6 w-6 text-muted-foreground" />
<p className="text-muted-foreground text-xs">
</p>
</div>
) : (
<div className="space-y-2">
{config.leftPanel.components.map((comp: PanelInlineComponent) => (
<div
key={comp.id}
className="flex items-center justify-between rounded-md border bg-background p-2"
>
<div className="flex-1">
<p className="text-xs font-medium">
{comp.label || comp.componentType}
</p>
<p className="text-muted-foreground text-[10px]">
{comp.componentType} | : ({comp.position?.x || 0}, {comp.position?.y || 0}) | : {comp.size?.width || 0}x{comp.size?.height || 0}
</p>
</div>
<Button
onClick={() => {
const updatedComponents = (config.leftPanel?.components || []).filter(
(c: PanelInlineComponent) => c.id !== comp.id
);
onChange({
...config,
leftPanel: {
...config.leftPanel,
components: updatedComponents,
},
});
}}
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
)}
{/* 좌측 패널 표시 컬럼 설정 - 드래그앤드롭 (커스텀 모드가 아닐 때만) */}
{config.leftPanel?.displayMode !== "custom" && (() => {
const selectedColumns = config.leftPanel?.columns || [];
const filteredTableColumns = leftTableColumns.filter((c) => !["company_code", "company_name"].includes(c.columnName));
const unselectedColumns = filteredTableColumns.filter((c) => !selectedColumns.some((sc) => sc.name === c.columnName));
const dbNumericTypes = ["numeric", "decimal", "integer", "bigint", "double precision", "real", "smallint", "int4", "int8", "float4", "float8"];
const inputNumericTypes = ["number", "decimal", "currency", "integer"];
const handleLeftDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = selectedColumns.findIndex((c) => c.name === active.id);
const newIndex = selectedColumns.findIndex((c) => c.name === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
updateLeftPanel({ columns: arrayMove([...selectedColumns], oldIndex, newIndex) });
}
}
};
return (
<div className="space-y-2">
<Label className="text-xs font-medium"> ({selectedColumns.length} )</Label>
<div className="max-h-[400px] overflow-y-auto rounded-md border p-2">
{leftTableColumns.length === 0 ? (
<p className="text-muted-foreground py-2 text-center text-xs"> ...</p>
) : (
<>
{/* 선택된 컬럼 - 드래그앤드롭 정렬 */}
{selectedColumns.length > 0 && (
<DndContext collisionDetection={closestCenter} onDragEnd={handleLeftDragEnd}>
<SortableContext items={selectedColumns.map((c) => c.name)} strategy={verticalListSortingStrategy}>
<div className="space-y-1">
{selectedColumns.map((col, index) => {
const colInfo = leftTableColumns.find((c) => c.columnName === col.name);
const isNumeric = colInfo && (
dbNumericTypes.includes(colInfo.dataType?.toLowerCase() || "") ||
inputNumericTypes.includes(colInfo.input_type?.toLowerCase() || "") ||
inputNumericTypes.includes(colInfo.webType?.toLowerCase() || "")
);
return (
<SortableColumnRow
key={col.name}
id={col.name}
col={col}
index={index}
isNumeric={!!isNumeric}
isEntityJoin={!!(col as any).isEntityJoin}
onLabelChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], label: value };
updateLeftPanel({ columns: newColumns });
}}
onWidthChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], width: value };
updateLeftPanel({ columns: newColumns });
}}
onFormatChange={(checked) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
updateLeftPanel({ columns: newColumns });
}}
onRemove={() => updateLeftPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
/>
);
})}
</div>
</SortableContext>
</DndContext>
)}
{/* 구분선 */}
{selectedColumns.length > 0 && unselectedColumns.length > 0 && (
<div className="border-border/60 my-2 flex items-center gap-2 border-t pt-2">
<span className="text-muted-foreground text-[10px]"> </span>
</div>
)}
{/* 미선택 컬럼 - 클릭으로 추가 */}
<div className="space-y-0.5">
{unselectedColumns.map((column) => (
<div
key={column.columnName}
className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1.5"
onClick={() => {
updateLeftPanel({ columns: [...selectedColumns, { name: column.columnName, label: column.columnLabel || column.columnName, width: 100 }] });
}}
>
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
<span className="text-muted-foreground truncate text-xs">{column.columnLabel || column.columnName}</span>
</div>
))}
</div>
{/* 좌측 패널 - Entity 조인 컬럼 아코디언 */}
{(() => {
const leftTable = config.leftPanel?.tableName || screenTableName;
const joinData = leftTable ? entityJoinColumns[leftTable] : null;
if (!joinData || joinData.joinTables.length === 0) return null;
return joinData.joinTables.map((joinTable, tableIndex) => {
const joinColumnsToShow = joinTable.availableColumns.filter((column) => {
const matchingJoinColumn = joinData.availableColumns.find(
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
);
if (!matchingJoinColumn) return false;
return !selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias);
});
const addedCount = joinTable.availableColumns.length - joinColumnsToShow.length;
if (joinColumnsToShow.length === 0 && addedCount === 0) return null;
return (
<details key={`join-${tableIndex}`} className="group">
<summary className="border-border/60 my-2 flex cursor-pointer list-none items-center gap-2 border-t pt-2 select-none">
<ChevronRight className="h-3 w-3 shrink-0 text-blue-500 transition-transform group-open:rotate-90" />
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
<span className="text-[10px] font-medium text-blue-600">{joinTable.tableName}</span>
{addedCount > 0 && (
<span className="rounded-full bg-blue-100 px-1.5 text-[9px] font-medium text-blue-600">{addedCount} </span>
)}
<span className="text-[9px] text-gray-400">{joinColumnsToShow.length} </span>
</summary>
<div className="space-y-0.5 pt-1">
{joinColumnsToShow.map((column, colIndex) => {
const matchingJoinColumn = joinData.availableColumns.find(
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
);
if (!matchingJoinColumn) return null;
return (
<div
key={colIndex}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-blue-50/60"
onClick={() => {
updateLeftPanel({
columns: [...selectedColumns, {
name: matchingJoinColumn.joinAlias,
label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel,
width: 100,
isEntityJoin: true,
joinInfo: {
sourceTable: leftTable!,
sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "",
referenceTable: matchingJoinColumn.tableName,
joinAlias: matchingJoinColumn.joinAlias,
},
}],
});
}}
>
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
<span className="truncate text-xs text-blue-700">{column.columnLabel || column.columnName}</span>
</div>
);
})}
{joinColumnsToShow.length === 0 && (
<p className="px-2 py-1 text-[10px] text-gray-400"> </p>
)}
</div>
</details>
);
});
})()}
</>
)}
</div>
</div>
);
})()}
</div>
{/* 좌측 패널 데이터 필터링 */}
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-xs"> </p>
<DataFilterConfigPanel
tableName={config.leftPanel?.tableName || screenTableName}
columns={leftTableColumns.map(
(col) =>
({
columnName: col.columnName,
columnLabel: col.columnLabel || col.columnName,
dataType: col.dataType || "text",
input_type: (col as any).input_type,
}) as any,
)}
config={config.leftPanel?.dataFilter}
onConfigChange={(dataFilter) => updateLeftPanel({ dataFilter })}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
/>
</div>
{/* 좌측 패널 버튼 설정 */}
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
{/* 버튼 표시 체크박스 */}
<div className="grid grid-cols-4 gap-2">
<div className="flex items-center gap-1">
<Checkbox
id="left-showSearch"
checked={config.leftPanel?.showSearch ?? false}
onCheckedChange={(checked) => updateLeftPanel({ showSearch: !!checked })}
/>
<label htmlFor="left-showSearch" className="text-xs"></label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id="left-showAdd"
checked={config.leftPanel?.showAdd ?? false}
onCheckedChange={(checked) => updateLeftPanel({ showAdd: !!checked })}
/>
<label htmlFor="left-showAdd" className="text-xs"></label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id="left-showEdit"
checked={config.leftPanel?.showEdit ?? true}
onCheckedChange={(checked) => updateLeftPanel({ showEdit: !!checked })}
/>
<label htmlFor="left-showEdit" className="text-xs"></label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id="left-showDelete"
checked={config.leftPanel?.showDelete ?? true}
onCheckedChange={(checked) => updateLeftPanel({ showDelete: !!checked })}
/>
<label htmlFor="left-showDelete" className="text-xs"></label>
</div>
</div>
{/* 추가 버튼 상세 설정 */}
{config.leftPanel?.showAdd && (
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={config.leftPanel?.addButton?.mode || "auto"}
onValueChange={(value: "auto" | "modal") =>
updateLeftPanel({
addButton: { ...config.leftPanel?.addButton, enabled: true, mode: value },
})
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ( )</SelectItem>
<SelectItem value="modal"> </SelectItem>
</SelectContent>
</Select>
</div>
{config.leftPanel?.addButton?.mode === "modal" && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<ScreenSelector
value={config.leftPanel?.addButton?.modalScreenId}
onChange={(screenId) =>
updateLeftPanel({
addButton: { ...config.leftPanel?.addButton, enabled: true, mode: "modal", modalScreenId: screenId },
})
}
/>
</div>
)}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={config.leftPanel?.addButton?.buttonLabel || ""}
onChange={(e) =>
updateLeftPanel({
addButton: {
...config.leftPanel?.addButton,
enabled: true,
mode: config.leftPanel?.addButton?.mode || "auto",
buttonLabel: e.target.value || undefined,
},
})
}
placeholder="추가"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
{/* 수정 버튼 상세 설정 */}
{(config.leftPanel?.showEdit ?? true) && (
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={config.leftPanel?.editButton?.mode || "auto"}
onValueChange={(value: "auto" | "modal") =>
updateLeftPanel({
editButton: { ...config.leftPanel?.editButton, enabled: true, mode: value },
})
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ()</SelectItem>
<SelectItem value="modal"> </SelectItem>
</SelectContent>
</Select>
</div>
{config.leftPanel?.editButton?.mode === "modal" && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<ScreenSelector
value={config.leftPanel?.editButton?.modalScreenId}
onChange={(screenId) =>
updateLeftPanel({
editButton: { ...config.leftPanel?.editButton, enabled: true, mode: "modal", modalScreenId: screenId },
})
}
/>
</div>
)}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={config.leftPanel?.editButton?.buttonLabel || ""}
onChange={(e) =>
updateLeftPanel({
editButton: {
...config.leftPanel?.editButton,
enabled: true,
mode: config.leftPanel?.editButton?.mode || "auto",
buttonLabel: e.target.value || undefined,
},
})
}
placeholder="수정"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
{/* ===== 우측 패널 모달 ===== */}
<Dialog open={activeModal === "right"} onOpenChange={(open) => !open && setActiveModal(null)}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base"> </DialogTitle>
<DialogDescription className="text-xs">/ , , </DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 우측 패널 설정 */}
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> ({relationshipType === "detail" ? "상세" : "조건 필터"})</h3>
<div className="space-y-2">
<Label> </Label>
<Input
value={localTitles.right}
onChange={(e) => {
setIsUserEditing(true);
setLocalTitles((prev) => ({ ...prev, right: e.target.value }));
}}
onBlur={() => {
setIsUserEditing(false);
updateRightPanel({ title: localTitles.right });
}}
placeholder="우측 패널 제목"
/>
</div>
{/* 헤더 높이 설정 - 숨김 처리 */}
{/* <div className="space-y-2">
<Label>헤더 높이 (px)</Label>
<Input
type="number"
value={config.rightPanel?.panelHeaderHeight || 48}
onChange={(e) => updateRightPanel({ panelHeaderHeight: parseInt(e.target.value) || 48 })}
placeholder="48"
min={32}
max={120}
/>
<p className="text-muted-foreground text-xs">패널 상단 헤더의 높이 (기본: 48px)</p>
</div> */}
{/* 관계 타입에 따라 테이블 선택 UI 변경 */}
{relationshipType === "detail" ? (
// 상세 모드: 좌측과 동일한 테이블 (자동 설정)
<div className="space-y-2">
<Label> ( )</Label>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
<p className="text-sm font-medium text-gray-900">
{config.leftPanel?.tableName || screenTableName || "테이블이 지정되지 않음"}
</p>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
</div>
) : (
// 조건 필터 모드: 전체 테이블에서 선택 가능
<div className="space-y-2">
<Label> </Label>
<Popover open={rightTableOpen} onOpenChange={setRightTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={rightTableOpen}
className="w-full justify-between"
>
{config.rightPanel?.tableName || "테이블을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="테이블 검색..." />
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{availableRightTables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName || ""} ${table.tableName}`}
onSelect={() => {
updateRightPanel({ tableName: table.tableName });
setRightTableOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.rightPanel?.tableName === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
{table.displayName || table.tableName}
{table.displayName && <span className="ml-2 text-xs text-gray-500">({table.tableName})</span>}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
)}
<div className="space-y-2">
<Label> </Label>
<Select
value={config.rightPanel?.displayMode || "list"}
onValueChange={(value: "list" | "table" | "custom") => updateRightPanel({ displayMode: value })}
>
<SelectTrigger className="h-10 bg-white">
<SelectValue placeholder="표시 모드 선택">
{(config.rightPanel?.displayMode || "list") === "list" ? "목록 (LIST)" : (config.rightPanel?.displayMode || "list") === "table" ? "테이블 (TABLE)" : "커스텀 (CUSTOM)"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="list">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> (LIST)</span>
<span className="text-xs text-gray-500"> ()</span>
</div>
</SelectItem>
<SelectItem value="table">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> (TABLE)</span>
<span className="text-xs text-gray-500"> </span>
</div>
</SelectItem>
<SelectItem value="custom">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> (CUSTOM)</span>
<span className="text-xs text-gray-500"> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
{config.rightPanel?.displayMode === "custom" && (
<p className="text-xs text-amber-600">
.
</p>
)}
</div>
{/* 🆕 커스텀 모드: 배치된 컴포넌트 목록 */}
{config.rightPanel?.displayMode === "custom" && (
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
{!config.rightPanel?.components || config.rightPanel.components.length === 0 ? (
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
<Move className="mx-auto mb-2 h-6 w-6 text-muted-foreground" />
<p className="text-muted-foreground text-xs">
</p>
</div>
) : (
<div className="space-y-2">
{config.rightPanel.components.map((comp: PanelInlineComponent) => (
<div
key={comp.id}
className="flex items-center justify-between rounded-md border bg-background p-2"
>
<div className="flex-1">
<p className="text-xs font-medium">
{comp.label || comp.componentType}
</p>
<p className="text-muted-foreground text-[10px]">
{comp.componentType} | : ({comp.position?.x || 0}, {comp.position?.y || 0}) | : {comp.size?.width || 0}x{comp.size?.height || 0}
</p>
</div>
<Button
onClick={() => {
const updatedComponents = (config.rightPanel?.components || []).filter(
(c: PanelInlineComponent) => c.id !== comp.id
);
onChange({
...config,
rightPanel: {
...config.rightPanel,
components: updatedComponents,
},
});
}}
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
)}
{/* 요약 표시 설정 (LIST 모드에서만, 커스텀 모드가 아닐 때) */}
{(config.rightPanel?.displayMode || "list") === "list" && (
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
<Label className="text-sm font-semibold"> </Label>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
type="number"
min="1"
max="10"
value={config.rightPanel?.summaryColumnCount ?? 3}
onChange={(e) => {
const value = parseInt(e.target.value) || 3;
updateRightPanel({ summaryColumnCount: value });
}}
className="bg-white"
/>
<p className="text-xs text-gray-500"> (기본: 3개)</p>
</div>
<div className="flex items-center justify-between space-x-2">
<div className="flex-1">
<Label className="text-xs"> </Label>
<p className="text-xs text-gray-500"> </p>
</div>
<Checkbox
checked={config.rightPanel?.summaryShowLabel ?? true}
onCheckedChange={(checked) => {
updateRightPanel({ summaryShowLabel: checked as boolean });
}}
/>
</div>
</div>
)}
{/* 필터 연결 컬럼 제거됨 - Entity 조인이 자동으로 관계를 처리 */}
{/* 우측 패널 표시 컬럼 설정 - 드래그앤드롭 */}
{(() => {
const selectedColumns = config.rightPanel?.columns || [];
const filteredTableColumns = rightTableColumns.filter((c) => !["company_code", "company_name"].includes(c.columnName));
const unselectedColumns = filteredTableColumns.filter((c) => !selectedColumns.some((sc) => sc.name === c.columnName));
const dbNumericTypes = ["numeric", "decimal", "integer", "bigint", "double precision", "real", "smallint", "int4", "int8", "float4", "float8"];
const inputNumericTypes = ["number", "decimal", "currency", "integer"];
const handleRightDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = selectedColumns.findIndex((c) => c.name === active.id);
const newIndex = selectedColumns.findIndex((c) => c.name === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
updateRightPanel({ columns: arrayMove([...selectedColumns], oldIndex, newIndex) });
}
}
};
return (
<div className="space-y-2">
<Label className="text-xs font-medium"> ({selectedColumns.length} )</Label>
<div className="max-h-[400px] overflow-y-auto rounded-md border p-2">
{rightTableColumns.length === 0 ? (
<p className="text-muted-foreground py-2 text-center text-xs"> </p>
) : (
<>
{selectedColumns.length > 0 && (
<DndContext collisionDetection={closestCenter} onDragEnd={handleRightDragEnd}>
<SortableContext items={selectedColumns.map((c) => c.name)} strategy={verticalListSortingStrategy}>
<div className="space-y-1">
{selectedColumns.map((col, index) => {
const colInfo = rightTableColumns.find((c) => c.columnName === col.name);
const isNumeric = colInfo && (
dbNumericTypes.includes(colInfo.dataType?.toLowerCase() || "") ||
inputNumericTypes.includes(colInfo.input_type?.toLowerCase() || "") ||
inputNumericTypes.includes(colInfo.webType?.toLowerCase() || "")
);
return (
<SortableColumnRow
key={col.name}
id={col.name}
col={col}
index={index}
isNumeric={!!isNumeric}
isEntityJoin={!!(col as any).isEntityJoin}
onLabelChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], label: value };
updateRightPanel({ columns: newColumns });
}}
onWidthChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], width: value };
updateRightPanel({ columns: newColumns });
}}
onFormatChange={(checked) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
updateRightPanel({ columns: newColumns });
}}
onRemove={() => updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
onShowInSummaryChange={(checked) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], showInSummary: checked };
updateRightPanel({ columns: newColumns });
}}
onShowInDetailChange={(checked) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], showInDetail: checked };
updateRightPanel({ columns: newColumns });
}}
/>
);
})}
</div>
</SortableContext>
</DndContext>
)}
{selectedColumns.length > 0 && unselectedColumns.length > 0 && (
<div className="border-border/60 my-2 flex items-center gap-2 border-t pt-2">
<span className="text-muted-foreground text-[10px]"> </span>
</div>
)}
<div className="space-y-0.5">
{unselectedColumns.map((column) => (
<div
key={column.columnName}
className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1.5"
onClick={() => {
updateRightPanel({ columns: [...selectedColumns, { name: column.columnName, label: column.columnLabel || column.columnName, width: 100 }] });
}}
>
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
<span className="text-muted-foreground truncate text-xs">{column.columnLabel || column.columnName}</span>
</div>
))}
</div>
{/* Entity 조인 컬럼 - 아코디언 (접기/펼치기) */}
{(() => {
const rightTable = config.rightPanel?.tableName;
const joinData = rightTable ? entityJoinColumns[rightTable] : null;
if (!joinData || joinData.joinTables.length === 0) return null;
return joinData.joinTables.map((joinTable, tableIndex) => {
const joinColumnsToShow = joinTable.availableColumns.filter((column) => {
const matchingJoinColumn = joinData.availableColumns.find(
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
);
if (!matchingJoinColumn) return false;
return !selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias);
});
const addedCount = joinTable.availableColumns.length - joinColumnsToShow.length;
if (joinColumnsToShow.length === 0 && addedCount === 0) return null;
return (
<details key={`join-${tableIndex}`} className="group">
<summary className="border-border/60 my-2 flex cursor-pointer list-none items-center gap-2 border-t pt-2 select-none">
<ChevronRight className="h-3 w-3 shrink-0 text-blue-500 transition-transform group-open:rotate-90" />
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
<span className="text-[10px] font-medium text-blue-600">{joinTable.tableName}</span>
{addedCount > 0 && (
<span className="rounded-full bg-blue-100 px-1.5 text-[9px] font-medium text-blue-600">{addedCount} </span>
)}
<span className="text-[9px] text-gray-400">{joinColumnsToShow.length} </span>
</summary>
<div className="space-y-0.5 pt-1">
{joinColumnsToShow.map((column, colIndex) => {
const matchingJoinColumn = joinData.availableColumns.find(
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
);
if (!matchingJoinColumn) return null;
return (
<div
key={colIndex}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-blue-50/60"
onClick={() => {
updateRightPanel({
columns: [...selectedColumns, {
name: matchingJoinColumn.joinAlias,
label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel,
width: 100,
isEntityJoin: true,
joinInfo: {
sourceTable: rightTable!,
sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "",
referenceTable: matchingJoinColumn.tableName,
joinAlias: matchingJoinColumn.joinAlias,
},
}],
});
}}
>
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
<span className="truncate text-xs text-blue-700">{column.columnLabel || column.columnName}</span>
</div>
);
})}
{joinColumnsToShow.length === 0 && (
<p className="px-2 py-1 text-[10px] text-gray-400"> </p>
)}
</div>
</details>
);
});
})()}
</>
)}
</div>
</div>
);
})()}
</div>
{/* 우측 패널 Entity 조인 컬럼은 표시 컬럼 목록에 통합됨 */}
{/* 우측 패널 데이터 필터링 */}
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-xs"> </p>
<DataFilterConfigPanel
tableName={config.rightPanel?.tableName}
columns={rightTableColumns.map(
(col) =>
({
columnName: col.columnName,
columnLabel: col.columnLabel || col.columnName,
dataType: col.dataType || "text",
input_type: (col as any).input_type,
}) as any,
)}
config={config.rightPanel?.dataFilter}
onConfigChange={(dataFilter) => updateRightPanel({ dataFilter })}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
/>
</div>
{/* 우측 패널 중복 제거 */}
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-xs"> </p>
</div>
<Switch
checked={config.rightPanel?.deduplication?.enabled ?? false}
onCheckedChange={(checked) => {
if (checked) {
updateRightPanel({
deduplication: {
enabled: true,
groupByColumn: "",
keepStrategy: "latest",
sortColumn: "start_date",
},
});
} else {
updateRightPanel({ deduplication: undefined });
}
}}
/>
</div>
{config.rightPanel?.deduplication?.enabled && (
<div className="space-y-3 border-l-2 pl-4">
{/* 중복 제거 기준 컬럼 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={config.rightPanel?.deduplication?.groupByColumn || ""}
onValueChange={(value) =>
updateRightPanel({
deduplication: { ...config.rightPanel?.deduplication!, groupByColumn: value },
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="기준 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{rightTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px]">
</p>
</div>
{/* 유지 전략 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={config.rightPanel?.deduplication?.keepStrategy || "latest"}
onValueChange={(value: any) =>
updateRightPanel({
deduplication: { ...config.rightPanel?.deduplication!, keepStrategy: value },
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="latest"> ( )</SelectItem>
<SelectItem value="earliest"> ( )</SelectItem>
<SelectItem value="current_date"> ( )</SelectItem>
<SelectItem value="base_price"> </SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px]">
{config.rightPanel?.deduplication?.keepStrategy === "latest" &&
"가장 최근에 추가된 데이터를 표시합니다"}
{config.rightPanel?.deduplication?.keepStrategy === "earliest" &&
"가장 먼저 추가된 데이터를 표시합니다"}
{config.rightPanel?.deduplication?.keepStrategy === "current_date" &&
"오늘 날짜 기준으로 유효한 기간의 데이터를 표시합니다"}
{config.rightPanel?.deduplication?.keepStrategy === "base_price" &&
"기준단가(base_price)로 체크된 데이터를 표시합니다"}
</p>
</div>
{/* 정렬 기준 컬럼 (latest/earliest만) */}
{(config.rightPanel?.deduplication?.keepStrategy === "latest" ||
config.rightPanel?.deduplication?.keepStrategy === "earliest") && (
<div>
<Label className="text-xs"> </Label>
<Select
value={config.rightPanel?.deduplication?.sortColumn || ""}
onValueChange={(value) =>
updateRightPanel({
deduplication: { ...config.rightPanel?.deduplication!, sortColumn: value },
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="정렬 기준 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{rightTableColumns
.filter(
(col) =>
col.dataType === "date" || col.dataType === "timestamp" || col.columnName.includes("date"),
)
.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px]">
/ ( )
</p>
</div>
)}
</div>
)}
</div>
{/* 🆕 우측 패널 수정 버튼 설정 */}
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-xs"> </p>
</div>
<Switch
checked={config.rightPanel?.editButton?.enabled ?? true}
onCheckedChange={(checked) => {
updateRightPanel({
editButton: {
enabled: checked,
mode: config.rightPanel?.editButton?.mode || "auto",
buttonLabel: config.rightPanel?.editButton?.buttonLabel,
buttonVariant: config.rightPanel?.editButton?.buttonVariant,
},
});
}}
/>
</div>
{(config.rightPanel?.editButton?.enabled ?? true) && (
<div className="space-y-3 border-l-2 pl-4">
{/* 수정 모드 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={config.rightPanel?.editButton?.mode || "auto"}
onValueChange={(value: "auto" | "modal") =>
updateRightPanel({
editButton: {
...config.rightPanel?.editButton,
mode: value,
enabled: config.rightPanel?.editButton?.enabled ?? true,
},
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ()</SelectItem>
<SelectItem value="modal"> </SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px]">
{config.rightPanel?.editButton?.mode === "modal"
? "지정한 화면을 모달로 열어 데이터를 수정합니다"
: "현재 위치에서 직접 데이터를 수정합니다"}
</p>
</div>
{/* 모달 화면 선택 (modal 모드일 때만) */}
{config.rightPanel?.editButton?.mode === "modal" && (
<div>
<Label className="text-xs"> </Label>
<ScreenSelector
value={config.rightPanel?.editButton?.modalScreenId}
onChange={(screenId) =>
updateRightPanel({
editButton: {
...config.rightPanel?.editButton!,
modalScreenId: screenId,
},
})
}
/>
<p className="text-muted-foreground mt-1 text-[10px]"> </p>
</div>
)}
{/* 버튼 라벨 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={config.rightPanel?.editButton?.buttonLabel || "수정"}
onChange={(e) =>
updateRightPanel({
editButton: {
...config.rightPanel?.editButton!,
buttonLabel: e.target.value,
enabled: config.rightPanel?.editButton?.enabled ?? true,
mode: config.rightPanel?.editButton?.mode || "auto",
},
})
}
className="h-8 text-xs"
placeholder="수정"
/>
</div>
{/* 버튼 스타일 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={config.rightPanel?.editButton?.buttonVariant || "outline"}
onValueChange={(value: any) =>
updateRightPanel({
editButton: {
...config.rightPanel?.editButton!,
buttonVariant: value,
enabled: config.rightPanel?.editButton?.enabled ?? true,
mode: config.rightPanel?.editButton?.mode || "auto",
},
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"> ()</SelectItem>
<SelectItem value="outline"></SelectItem>
<SelectItem value="ghost"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 🆕 그룹핑 기준 컬럼 설정 (modal 모드일 때만 표시) */}
{config.rightPanel?.editButton?.mode === "modal" && (
<GroupByColumnsSelector
tableName={config.rightPanel?.tableName}
selectedColumns={config.rightPanel?.editButton?.groupByColumns || []}
onChange={(columns) => {
updateRightPanel({
editButton: {
...config.rightPanel?.editButton!,
groupByColumns: columns,
enabled: config.rightPanel?.editButton?.enabled ?? true,
mode: config.rightPanel?.editButton?.mode || "auto",
},
});
}}
/>
)}
</div>
)}
</div>
{/* 🆕 우측 패널 추가 버튼 설정 */}
{config.rightPanel?.showAdd && (
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-xs"> </p>
</div>
</div>
<div className="space-y-3 border-l-2 pl-4">
<div>
<Label className="text-xs"> </Label>
<Select
value={config.rightPanel?.addButton?.mode || "auto"}
onValueChange={(value: "auto" | "modal") =>
updateRightPanel({
addButton: {
...config.rightPanel?.addButton,
mode: value,
enabled: true,
},
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ( )</SelectItem>
<SelectItem value="modal"> </SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px]">
{config.rightPanel?.addButton?.mode === "modal"
? "지정한 화면을 모달로 열어 데이터를 추가합니다"
: "내장 폼으로 데이터를 추가합니다"}
</p>
</div>
{config.rightPanel?.addButton?.mode === "modal" && (
<div>
<Label className="text-xs"> </Label>
<ScreenSelector
value={config.rightPanel?.addButton?.modalScreenId}
onChange={(screenId) =>
updateRightPanel({
addButton: {
...config.rightPanel?.addButton!,
modalScreenId: screenId,
},
})
}
/>
</div>
)}
<div>
<Label className="text-xs"> </Label>
<Input
value={config.rightPanel?.addButton?.buttonLabel || "추가"}
onChange={(e) =>
updateRightPanel({
addButton: {
...config.rightPanel?.addButton!,
buttonLabel: e.target.value,
enabled: true,
mode: config.rightPanel?.addButton?.mode || "auto",
},
})
}
placeholder="추가"
className="h-8 text-xs"
/>
</div>
</div>
</div>
)}
{/* 🆕 우측 패널 삭제 버튼 설정 */}
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
</div>
<Switch
checked={config.rightPanel?.deleteButton?.enabled ?? true}
onCheckedChange={(checked) => {
updateRightPanel({
deleteButton: {
enabled: checked,
buttonLabel: config.rightPanel?.deleteButton?.buttonLabel,
buttonVariant: config.rightPanel?.deleteButton?.buttonVariant,
},
});
}}
/>
</div>
{(config.rightPanel?.deleteButton?.enabled ?? true) && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
{/* 버튼 라벨 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.rightPanel?.deleteButton?.buttonLabel || ""}
placeholder="삭제"
onChange={(e) => {
updateRightPanel({
deleteButton: {
...config.rightPanel?.deleteButton!,
buttonLabel: e.target.value || undefined,
enabled: config.rightPanel?.deleteButton?.enabled ?? true,
},
});
}}
className="h-8 text-xs"
/>
</div>
{/* 버튼 스타일 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.rightPanel?.deleteButton?.buttonVariant || "ghost"}
onValueChange={(value: "default" | "outline" | "ghost" | "destructive") => {
updateRightPanel({
deleteButton: {
...config.rightPanel?.deleteButton!,
buttonVariant: value,
enabled: config.rightPanel?.deleteButton?.enabled ?? true,
},
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ghost">Ghost ()</SelectItem>
<SelectItem value="outline">Outline</SelectItem>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="destructive">Destructive ()</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 삭제 확인 메시지 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.rightPanel?.deleteButton?.confirmMessage || ""}
placeholder="정말 삭제하시겠습니까?"
onChange={(e) => {
updateRightPanel({
deleteButton: {
...config.rightPanel?.deleteButton!,
confirmMessage: e.target.value || undefined,
enabled: config.rightPanel?.deleteButton?.enabled ?? true,
},
});
}}
className="h-8 text-xs"
/>
</div>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
{/* ===== 추가 탭 모달 ===== */}
<Dialog open={activeModal === "tabs"} onOpenChange={(open) => !open && setActiveModal(null)}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base"> </DialogTitle>
<DialogDescription className="text-xs"> </DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-xs">
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
const newTab: AdditionalTabConfig = {
tabId: `tab_${Date.now()}`,
label: `${(config.rightPanel?.additionalTabs?.length || 0) + 1}`,
title: "",
tableName: "",
displayMode: "list",
showSearch: false,
showAdd: false,
showEdit: true,
showDelete: true,
summaryColumnCount: 3,
summaryShowLabel: true,
};
updateRightPanel({
additionalTabs: [...(config.rightPanel?.additionalTabs || []), newTab],
});
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{/* 추가된 탭 목록 */}
{(config.rightPanel?.additionalTabs?.length || 0) > 0 ? (
<Accordion type="multiple" className="space-y-2">
{config.rightPanel?.additionalTabs?.map((tab, tabIndex) => (
<AdditionalTabConfigPanel
key={tab.tabId}
tab={tab}
tabIndex={tabIndex}
config={config}
updateRightPanel={updateRightPanel}
availableRightTables={availableRightTables}
leftTableColumns={leftTableColumns}
menuObjid={menuObjid}
loadedTableColumns={loadedTableColumns}
loadTableColumns={loadTableColumns}
loadingColumns={loadingColumns}
entityJoinColumns={entityJoinColumns}
/>
))}
</Accordion>
) : (
<div className="rounded-lg border border-dashed p-4 text-center">
<p className="text-xs text-gray-500">
. [ ] .
</p>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
};