- Implemented a new progress bar rendering function in the SplitPanelLayoutComponent to visually represent the ratio of child to parent values. - Enhanced the SortableColumnRow component to support progress column configuration, allowing users to set current and maximum values through a popover interface. - Updated the AdditionalTabConfigPanel to include options for adding progress columns, improving user experience in managing data visualization. These changes significantly enhance the functionality and usability of the split panel layout by providing visual progress indicators and configuration options for users.
3365 lines
158 KiB
TypeScript
3365 lines
158 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, onProgressChange, availableChildColumns, availableParentColumns,
|
|
}: {
|
|
id: string;
|
|
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean; type?: string; numerator?: string; denominator?: string };
|
|
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;
|
|
onProgressChange?: (updates: { numerator?: string; denominator?: string }) => void;
|
|
availableChildColumns?: Array<{ columnName: string; columnLabel: string }>;
|
|
availableParentColumns?: Array<{ columnName: string; columnLabel: string }>;
|
|
}) {
|
|
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",
|
|
col.type === "progress" && "border-emerald-200 bg-emerald-50/30",
|
|
)}
|
|
>
|
|
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
|
|
<GripVertical className="h-3 w-3" />
|
|
</div>
|
|
{col.type === "progress" ? (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<button className="shrink-0 cursor-pointer rounded bg-emerald-100 px-1 text-[9px] font-medium text-emerald-700 hover:bg-emerald-200" title="클릭하여 설정 변경">BAR</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-56 space-y-2 p-3" align="start">
|
|
<p className="text-xs font-medium">프로그레스 설정</p>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">현재값 (자식 컬럼)</Label>
|
|
<Select value={col.numerator || ""} onValueChange={(v) => onProgressChange?.({ numerator: v })}>
|
|
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="컬럼 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
{(availableChildColumns || []).map((c) => (
|
|
<SelectItem key={c.columnName} value={c.columnName} className="text-xs">{c.columnLabel || c.columnName}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">최대값 (부모 컬럼)</Label>
|
|
<Select value={col.denominator || ""} onValueChange={(v) => onProgressChange?.({ denominator: v })}>
|
|
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="컬럼 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
{(availableParentColumns || []).map((c) => (
|
|
<SelectItem key={c.columnName} value={c.columnName} className="text-xs">{c.columnLabel || c.columnName}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : 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 });
|
|
}}
|
|
onProgressChange={(updates) => {
|
|
const newColumns = [...selectedColumns];
|
|
newColumns[index] = { ...newColumns[index], ...updates };
|
|
updateTab({ columns: newColumns });
|
|
}}
|
|
availableChildColumns={tabColumns.map((c) => ({ columnName: c.columnName, columnLabel: c.columnLabel || c.columnName }))}
|
|
availableParentColumns={leftTableColumns.map((c) => ({ columnName: c.columnName, columnLabel: c.columnLabel || c.columnName }))}
|
|
/>
|
|
);
|
|
})}
|
|
</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>
|
|
|
|
{/* 프로그레스 컬럼 추가 */}
|
|
{tab.tableName && (
|
|
<div className="border-border/60 my-2 border-t pt-2">
|
|
<details className="group">
|
|
<summary className="flex cursor-pointer list-none items-center gap-2 select-none">
|
|
<ChevronRight className="h-3 w-3 shrink-0 text-emerald-500 transition-transform group-open:rotate-90" />
|
|
<span className="text-[10px] font-medium text-emerald-600">프로그레스 컬럼 추가</span>
|
|
</summary>
|
|
<div className="mt-2 space-y-2 rounded-md border border-emerald-200 bg-emerald-50/50 p-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">라벨</Label>
|
|
<Input
|
|
id={`tab-${tabIndex}-progress-label`}
|
|
placeholder="예: 샷수 현황"
|
|
className="h-7 text-xs"
|
|
defaultValue=""
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">현재값 (자식 컬럼)</Label>
|
|
<Select
|
|
onValueChange={(v) => {
|
|
const el = document.getElementById(`tab-${tabIndex}-progress-numerator`) as HTMLInputElement;
|
|
if (el) el.value = v;
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-7 text-[10px]">
|
|
<SelectValue placeholder="컬럼 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{tabColumns.map((col) => (
|
|
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
|
{col.columnLabel || col.columnName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<input type="hidden" id={`tab-${tabIndex}-progress-numerator`} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">최대값 (부모 컬럼)</Label>
|
|
<Select
|
|
onValueChange={(v) => {
|
|
const el = document.getElementById(`tab-${tabIndex}-progress-denominator`) as HTMLInputElement;
|
|
if (el) el.value = v;
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-7 text-[10px]">
|
|
<SelectValue placeholder="컬럼 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{leftTableColumns.map((col) => (
|
|
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
|
{col.columnLabel || col.columnName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<input type="hidden" id={`tab-${tabIndex}-progress-denominator`} />
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 w-full text-xs text-emerald-700 border-emerald-300 hover:bg-emerald-100"
|
|
onClick={() => {
|
|
const labelEl = document.getElementById(`tab-${tabIndex}-progress-label`) as HTMLInputElement;
|
|
const numEl = document.getElementById(`tab-${tabIndex}-progress-numerator`) as HTMLInputElement;
|
|
const denEl = document.getElementById(`tab-${tabIndex}-progress-denominator`) as HTMLInputElement;
|
|
const label = labelEl?.value || "프로그레스";
|
|
const numerator = numEl?.value;
|
|
const denominator = denEl?.value;
|
|
if (!numerator || !denominator) return;
|
|
updateTab({
|
|
columns: [
|
|
...selectedColumns,
|
|
{
|
|
name: `progress_${numerator}_${denominator}`,
|
|
label,
|
|
width: 200,
|
|
type: "progress",
|
|
numerator,
|
|
denominator,
|
|
} as any,
|
|
],
|
|
});
|
|
if (labelEl) labelEl.value = "";
|
|
}}
|
|
>
|
|
추가
|
|
</Button>
|
|
</div>
|
|
</details>
|
|
</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(() => {
|
|
return config.rightPanel?.tableName || "";
|
|
}, [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 = allTables;
|
|
|
|
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>
|
|
<p className="text-muted-foreground text-xs">좌측 항목 선택 시 우측에 어떤 형태로 데이터를 보여줄지 설정합니다</p>
|
|
<Select
|
|
value={relationshipType}
|
|
onValueChange={(value: "join" | "detail") => {
|
|
updateRightPanel({
|
|
relation: { ...config.rightPanel?.relation, type: value },
|
|
});
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-10 bg-white">
|
|
<SelectValue placeholder="표시 방식 선택">
|
|
{relationshipType === "detail" ? "선택 시 표시" : "연관 목록"}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="detail">
|
|
<div className="flex flex-col py-1">
|
|
<span className="text-sm font-medium">선택 시 표시</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">연관 목록</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 변경 */}
|
|
<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>
|
|
);
|
|
};
|
|
|