806 lines
32 KiB
TypeScript
806 lines
32 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|||
|
|
import {
|
|||
|
|
DndContext,
|
|||
|
|
closestCenter,
|
|||
|
|
KeyboardSensor,
|
|||
|
|
PointerSensor,
|
|||
|
|
useSensor,
|
|||
|
|
useSensors,
|
|||
|
|
type DragEndEvent,
|
|||
|
|
} from "@dnd-kit/core";
|
|||
|
|
import {
|
|||
|
|
arrayMove,
|
|||
|
|
SortableContext,
|
|||
|
|
sortableKeyboardCoordinates,
|
|||
|
|
verticalListSortingStrategy,
|
|||
|
|
} from "@dnd-kit/sortable";
|
|||
|
|
import { Plus, Settings2 } from "lucide-react";
|
|||
|
|
import { Button } from "@/components/ui/button";
|
|||
|
|
import { Label } from "@/components/ui/label";
|
|||
|
|
import { Input } from "@/components/ui/input";
|
|||
|
|
import { Switch } from "@/components/ui/switch";
|
|||
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|||
|
|
import {
|
|||
|
|
Dialog,
|
|||
|
|
DialogContent,
|
|||
|
|
DialogDescription,
|
|||
|
|
DialogFooter,
|
|||
|
|
DialogHeader,
|
|||
|
|
DialogTitle,
|
|||
|
|
} from "@/components/ui/dialog";
|
|||
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|||
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||
|
|
import { cn } from "@/lib/utils";
|
|||
|
|
import { apiClient } from "@/lib/api/client";
|
|||
|
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
|||
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|||
|
|
import type { ColumnConfig, SearchColumnConfig, GroupingConfig, ColumnDisplayConfig, EntityReferenceConfig } from "./types";
|
|||
|
|
import { SortableColumnItem } from "./components/SortableColumnItem";
|
|||
|
|
import { SearchableColumnSelect } from "./components/SearchableColumnSelect";
|
|||
|
|
|
|||
|
|
interface ColumnInfo {
|
|||
|
|
column_name: string;
|
|||
|
|
data_type: string;
|
|||
|
|
column_comment?: string;
|
|||
|
|
input_type?: string;
|
|||
|
|
web_type?: string;
|
|||
|
|
reference_table?: string;
|
|||
|
|
reference_column?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 참조 테이블 컬럼 정보
|
|||
|
|
interface ReferenceColumnInfo {
|
|||
|
|
columnName: string;
|
|||
|
|
displayName: string;
|
|||
|
|
dataType: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface ColumnConfigModalProps {
|
|||
|
|
open: boolean;
|
|||
|
|
onOpenChange: (open: boolean) => void;
|
|||
|
|
tableName: string;
|
|||
|
|
displayColumns: ColumnConfig[];
|
|||
|
|
searchColumns?: SearchColumnConfig[];
|
|||
|
|
grouping?: GroupingConfig;
|
|||
|
|
showSearch?: boolean;
|
|||
|
|
onSave: (config: {
|
|||
|
|
displayColumns: ColumnConfig[];
|
|||
|
|
searchColumns: SearchColumnConfig[];
|
|||
|
|
grouping: GroupingConfig;
|
|||
|
|
showSearch: boolean;
|
|||
|
|
}) => void;
|
|||
|
|
side: "left" | "right"; // 좌측/우측 패널 구분
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const ColumnConfigModal: React.FC<ColumnConfigModalProps> = ({
|
|||
|
|
open,
|
|||
|
|
onOpenChange,
|
|||
|
|
tableName,
|
|||
|
|
displayColumns: initialDisplayColumns,
|
|||
|
|
searchColumns: initialSearchColumns,
|
|||
|
|
grouping: initialGrouping,
|
|||
|
|
showSearch: initialShowSearch,
|
|||
|
|
onSave,
|
|||
|
|
side,
|
|||
|
|
}) => {
|
|||
|
|
// 로컬 상태 (모달 내에서만 사용, 저장 시 부모로 전달)
|
|||
|
|
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
|
|||
|
|
const [searchColumns, setSearchColumns] = useState<SearchColumnConfig[]>([]);
|
|||
|
|
const [grouping, setGrouping] = useState<GroupingConfig>({ enabled: false, groupByColumn: "" });
|
|||
|
|
const [showSearch, setShowSearch] = useState(false);
|
|||
|
|
|
|||
|
|
// 컬럼 세부설정 모달
|
|||
|
|
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
|||
|
|
const [editingColumnIndex, setEditingColumnIndex] = useState<number | null>(null);
|
|||
|
|
const [editingColumn, setEditingColumn] = useState<ColumnConfig | null>(null);
|
|||
|
|
|
|||
|
|
// 테이블 컬럼 목록
|
|||
|
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
|||
|
|
const [columnsLoading, setColumnsLoading] = useState(false);
|
|||
|
|
|
|||
|
|
// 엔티티 참조 관련 상태
|
|||
|
|
const [entityReferenceColumns, setEntityReferenceColumns] = useState<Map<string, ReferenceColumnInfo[]>>(new Map());
|
|||
|
|
const [loadingEntityColumns, setLoadingEntityColumns] = useState<Set<string>>(new Set());
|
|||
|
|
|
|||
|
|
// 드래그 센서
|
|||
|
|
const sensors = useSensors(
|
|||
|
|
useSensor(PointerSensor, {
|
|||
|
|
activationConstraint: {
|
|||
|
|
distance: 8,
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
useSensor(KeyboardSensor, {
|
|||
|
|
coordinateGetter: sortableKeyboardCoordinates,
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 초기값 설정
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (open) {
|
|||
|
|
setDisplayColumns(initialDisplayColumns || []);
|
|||
|
|
setSearchColumns(initialSearchColumns || []);
|
|||
|
|
setGrouping(initialGrouping || { enabled: false, groupByColumn: "" });
|
|||
|
|
setShowSearch(initialShowSearch || false);
|
|||
|
|
}
|
|||
|
|
}, [open, initialDisplayColumns, initialSearchColumns, initialGrouping, initialShowSearch]);
|
|||
|
|
|
|||
|
|
// 테이블 컬럼 로드 (entity 타입 정보 포함)
|
|||
|
|
const loadColumns = useCallback(async () => {
|
|||
|
|
if (!tableName) {
|
|||
|
|
setColumns([]);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setColumnsLoading(true);
|
|||
|
|
try {
|
|||
|
|
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=200`);
|
|||
|
|
|
|||
|
|
let columnList: any[] = [];
|
|||
|
|
if (response.data?.success && response.data?.data?.columns) {
|
|||
|
|
columnList = response.data.data.columns;
|
|||
|
|
} else if (Array.isArray(response.data?.data?.columns)) {
|
|||
|
|
columnList = response.data.data.columns;
|
|||
|
|
} else if (Array.isArray(response.data?.data)) {
|
|||
|
|
columnList = response.data.data;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// entity 타입 정보를 포함하여 변환
|
|||
|
|
const transformedColumns = columnList.map((c: any) => ({
|
|||
|
|
column_name: c.columnName ?? c.column_name ?? c.name ?? "",
|
|||
|
|
data_type: c.dataType ?? c.data_type ?? c.type ?? "",
|
|||
|
|
column_comment: c.displayName ?? c.column_comment ?? c.label ?? "",
|
|||
|
|
input_type: c.inputType ?? c.input_type ?? "",
|
|||
|
|
web_type: c.webType ?? c.web_type ?? "",
|
|||
|
|
reference_table: c.referenceTable ?? c.reference_table ?? "",
|
|||
|
|
reference_column: c.referenceColumn ?? c.reference_column ?? "",
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
setColumns(transformedColumns);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error("컬럼 목록 로드 실패:", error);
|
|||
|
|
setColumns([]);
|
|||
|
|
} finally {
|
|||
|
|
setColumnsLoading(false);
|
|||
|
|
}
|
|||
|
|
}, [tableName]);
|
|||
|
|
|
|||
|
|
// 엔티티 참조 테이블의 컬럼 목록 로드
|
|||
|
|
const loadEntityReferenceColumns = useCallback(async (columnName: string, referenceTable: string) => {
|
|||
|
|
if (!referenceTable || entityReferenceColumns.has(columnName)) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setLoadingEntityColumns(prev => new Set(prev).add(columnName));
|
|||
|
|
try {
|
|||
|
|
const result = await entityJoinApi.getReferenceTableColumns(referenceTable);
|
|||
|
|
if (result?.columns) {
|
|||
|
|
setEntityReferenceColumns(prev => {
|
|||
|
|
const newMap = new Map(prev);
|
|||
|
|
newMap.set(columnName, result.columns);
|
|||
|
|
return newMap;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error(`엔티티 참조 컬럼 로드 실패 (${referenceTable}):`, error);
|
|||
|
|
} finally {
|
|||
|
|
setLoadingEntityColumns(prev => {
|
|||
|
|
const newSet = new Set(prev);
|
|||
|
|
newSet.delete(columnName);
|
|||
|
|
return newSet;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}, [entityReferenceColumns]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (open && tableName) {
|
|||
|
|
loadColumns();
|
|||
|
|
}
|
|||
|
|
}, [open, tableName, loadColumns]);
|
|||
|
|
|
|||
|
|
// 드래그 종료 핸들러
|
|||
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|||
|
|
const { active, over } = event;
|
|||
|
|
|
|||
|
|
if (over && active.id !== over.id) {
|
|||
|
|
const oldIndex = displayColumns.findIndex((col, idx) => `col-${idx}` === active.id);
|
|||
|
|
const newIndex = displayColumns.findIndex((col, idx) => `col-${idx}` === over.id);
|
|||
|
|
|
|||
|
|
if (oldIndex !== -1 && newIndex !== -1) {
|
|||
|
|
setDisplayColumns(arrayMove(displayColumns, oldIndex, newIndex));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 컬럼 추가
|
|||
|
|
const handleAddColumn = () => {
|
|||
|
|
setDisplayColumns([
|
|||
|
|
...displayColumns,
|
|||
|
|
{
|
|||
|
|
name: "",
|
|||
|
|
label: "",
|
|||
|
|
displayRow: side === "left" ? "name" : "info",
|
|||
|
|
sourceTable: tableName,
|
|||
|
|
},
|
|||
|
|
]);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 컬럼 삭제
|
|||
|
|
const handleRemoveColumn = (index: number) => {
|
|||
|
|
setDisplayColumns(displayColumns.filter((_, i) => i !== index));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 컬럼 업데이트 (entity 타입이면 참조 테이블 컬럼도 로드)
|
|||
|
|
const handleUpdateColumn = (index: number, updates: Partial<ColumnConfig>) => {
|
|||
|
|
const newColumns = [...displayColumns];
|
|||
|
|
newColumns[index] = { ...newColumns[index], ...updates };
|
|||
|
|
setDisplayColumns(newColumns);
|
|||
|
|
|
|||
|
|
// 컬럼명이 변경된 경우 entity 타입인지 확인하고 참조 테이블 컬럼 로드
|
|||
|
|
if (updates.name) {
|
|||
|
|
const columnInfo = columns.find(c => c.column_name === updates.name);
|
|||
|
|
if (columnInfo && (columnInfo.input_type === 'entity' || columnInfo.web_type === 'entity')) {
|
|||
|
|
if (columnInfo.reference_table) {
|
|||
|
|
loadEntityReferenceColumns(updates.name, columnInfo.reference_table);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 컬럼 세부설정 열기 (entity 타입이면 참조 테이블 컬럼도 로드)
|
|||
|
|
const handleOpenDetailSettings = (index: number) => {
|
|||
|
|
const column = displayColumns[index];
|
|||
|
|
setEditingColumnIndex(index);
|
|||
|
|
setEditingColumn({ ...column });
|
|||
|
|
setDetailModalOpen(true);
|
|||
|
|
|
|||
|
|
// entity 타입인지 확인하고 참조 테이블 컬럼 로드
|
|||
|
|
if (column.name) {
|
|||
|
|
const columnInfo = columns.find(c => c.column_name === column.name);
|
|||
|
|
if (columnInfo && (columnInfo.input_type === 'entity' || columnInfo.web_type === 'entity')) {
|
|||
|
|
if (columnInfo.reference_table) {
|
|||
|
|
loadEntityReferenceColumns(column.name, columnInfo.reference_table);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 컬럼 세부설정 저장
|
|||
|
|
const handleSaveDetailSettings = () => {
|
|||
|
|
if (editingColumnIndex !== null && editingColumn) {
|
|||
|
|
handleUpdateColumn(editingColumnIndex, editingColumn);
|
|||
|
|
}
|
|||
|
|
setDetailModalOpen(false);
|
|||
|
|
setEditingColumnIndex(null);
|
|||
|
|
setEditingColumn(null);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 검색 컬럼 추가
|
|||
|
|
const handleAddSearchColumn = () => {
|
|||
|
|
setSearchColumns([...searchColumns, { columnName: "", label: "" }]);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 검색 컬럼 삭제
|
|||
|
|
const handleRemoveSearchColumn = (index: number) => {
|
|||
|
|
setSearchColumns(searchColumns.filter((_, i) => i !== index));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 검색 컬럼 업데이트
|
|||
|
|
const handleUpdateSearchColumn = (index: number, columnName: string) => {
|
|||
|
|
const newColumns = [...searchColumns];
|
|||
|
|
newColumns[index] = { ...newColumns[index], columnName };
|
|||
|
|
setSearchColumns(newColumns);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 저장
|
|||
|
|
const handleSave = () => {
|
|||
|
|
onSave({
|
|||
|
|
displayColumns,
|
|||
|
|
searchColumns,
|
|||
|
|
grouping,
|
|||
|
|
showSearch,
|
|||
|
|
});
|
|||
|
|
onOpenChange(false);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 엔티티 표시 컬럼 토글
|
|||
|
|
const toggleEntityDisplayColumn = (selectedColumn: string) => {
|
|||
|
|
if (!editingColumn) return;
|
|||
|
|
|
|||
|
|
const currentDisplayColumns = editingColumn.entityReference?.displayColumns || [];
|
|||
|
|
const newDisplayColumns = currentDisplayColumns.includes(selectedColumn)
|
|||
|
|
? currentDisplayColumns.filter(col => col !== selectedColumn)
|
|||
|
|
: [...currentDisplayColumns, selectedColumn];
|
|||
|
|
|
|||
|
|
setEditingColumn({
|
|||
|
|
...editingColumn,
|
|||
|
|
entityReference: {
|
|||
|
|
...editingColumn.entityReference,
|
|||
|
|
displayColumns: newDisplayColumns,
|
|||
|
|
} as EntityReferenceConfig,
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 현재 편집 중인 컬럼이 entity 타입인지 확인
|
|||
|
|
const getEditingColumnEntityInfo = useCallback(() => {
|
|||
|
|
if (!editingColumn?.name) return null;
|
|||
|
|
const columnInfo = columns.find(c => c.column_name === editingColumn.name);
|
|||
|
|
if (!columnInfo) return null;
|
|||
|
|
if (columnInfo.input_type !== 'entity' && columnInfo.web_type !== 'entity') return null;
|
|||
|
|
return {
|
|||
|
|
referenceTable: columnInfo.reference_table || '',
|
|||
|
|
referenceColumns: entityReferenceColumns.get(editingColumn.name) || [],
|
|||
|
|
isLoading: loadingEntityColumns.has(editingColumn.name),
|
|||
|
|
};
|
|||
|
|
}, [editingColumn, columns, entityReferenceColumns, loadingEntityColumns]);
|
|||
|
|
|
|||
|
|
// 이미 선택된 컬럼명 목록 (중복 선택 방지용)
|
|||
|
|
const selectedColumnNames = displayColumns.map((col) => col.name).filter(Boolean);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<>
|
|||
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|||
|
|
<DialogContent className="flex h-[80vh] max-w-[95vw] flex-col overflow-hidden sm:max-w-[700px]">
|
|||
|
|
<DialogHeader className="shrink-0">
|
|||
|
|
<DialogTitle className="flex items-center gap-2">
|
|||
|
|
<Settings2 className="h-5 w-5" />
|
|||
|
|
{side === "left" ? "좌측" : "우측"} 패널 컬럼 설정
|
|||
|
|
</DialogTitle>
|
|||
|
|
<DialogDescription>
|
|||
|
|
표시할 컬럼을 추가하고 순서를 드래그로 변경할 수 있습니다.
|
|||
|
|
</DialogDescription>
|
|||
|
|
</DialogHeader>
|
|||
|
|
|
|||
|
|
<Tabs defaultValue="columns" className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|||
|
|
<TabsList className="grid w-full shrink-0 grid-cols-3">
|
|||
|
|
<TabsTrigger value="columns">표시 컬럼</TabsTrigger>
|
|||
|
|
<TabsTrigger value="grouping" disabled={side === "right"}>
|
|||
|
|
그룹핑
|
|||
|
|
</TabsTrigger>
|
|||
|
|
<TabsTrigger value="search">검색</TabsTrigger>
|
|||
|
|
</TabsList>
|
|||
|
|
|
|||
|
|
{/* 표시 컬럼 탭 */}
|
|||
|
|
<TabsContent value="columns" className="mt-4 flex min-h-0 flex-1 flex-col overflow-hidden data-[state=inactive]:hidden">
|
|||
|
|
<div className="flex shrink-0 items-center justify-between mb-3">
|
|||
|
|
<Label className="text-sm font-medium">
|
|||
|
|
표시할 컬럼 ({displayColumns.length}개)
|
|||
|
|
</Label>
|
|||
|
|
<Button size="sm" variant="outline" onClick={handleAddColumn}>
|
|||
|
|
<Plus className="mr-1 h-4 w-4" />
|
|||
|
|
컬럼 추가
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<ScrollArea className="min-h-0 flex-1">
|
|||
|
|
<div className="pr-4">
|
|||
|
|
{displayColumns.length === 0 ? (
|
|||
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|||
|
|
<p className="text-muted-foreground text-sm mb-2">
|
|||
|
|
표시할 컬럼이 없습니다
|
|||
|
|
</p>
|
|||
|
|
<Button size="sm" variant="outline" onClick={handleAddColumn}>
|
|||
|
|
<Plus className="mr-1 h-4 w-4" />
|
|||
|
|
첫 번째 컬럼 추가
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<DndContext
|
|||
|
|
sensors={sensors}
|
|||
|
|
collisionDetection={closestCenter}
|
|||
|
|
onDragEnd={handleDragEnd}
|
|||
|
|
>
|
|||
|
|
<SortableContext
|
|||
|
|
items={displayColumns.map((_, idx) => `col-${idx}`)}
|
|||
|
|
strategy={verticalListSortingStrategy}
|
|||
|
|
>
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
{displayColumns.map((col, index) => (
|
|||
|
|
<div key={`col-${index}`} className="space-y-2">
|
|||
|
|
<SortableColumnItem
|
|||
|
|
id={`col-${index}`}
|
|||
|
|
column={col}
|
|||
|
|
index={index}
|
|||
|
|
onSettingsClick={() => handleOpenDetailSettings(index)}
|
|||
|
|
onRemove={() => handleRemoveColumn(index)}
|
|||
|
|
showGroupingSettings={grouping.enabled}
|
|||
|
|
/>
|
|||
|
|
{/* 컬럼 빠른 선택 (인라인) */}
|
|||
|
|
{!col.name && (
|
|||
|
|
<div className="ml-6 pl-2 border-l-2 border-muted">
|
|||
|
|
<SearchableColumnSelect
|
|||
|
|
tableName={tableName}
|
|||
|
|
value={col.name}
|
|||
|
|
onValueChange={(value) => {
|
|||
|
|
const colInfo = columns.find((c) => c.column_name === value);
|
|||
|
|
handleUpdateColumn(index, {
|
|||
|
|
name: value,
|
|||
|
|
label: colInfo?.column_comment || "",
|
|||
|
|
});
|
|||
|
|
}}
|
|||
|
|
excludeColumns={selectedColumnNames}
|
|||
|
|
placeholder="컬럼을 선택하세요"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</SortableContext>
|
|||
|
|
</DndContext>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</ScrollArea>
|
|||
|
|
</TabsContent>
|
|||
|
|
|
|||
|
|
{/* 그룹핑 탭 (좌측 패널만) */}
|
|||
|
|
<TabsContent value="grouping" className="mt-4 flex min-h-0 flex-1 flex-col overflow-hidden data-[state=inactive]:hidden">
|
|||
|
|
<ScrollArea className="min-h-0 flex-1">
|
|||
|
|
<div className="space-y-4 pr-4">
|
|||
|
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
|||
|
|
<div>
|
|||
|
|
<Label className="text-sm font-medium">그룹핑 사용</Label>
|
|||
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|||
|
|
동일한 값을 가진 행들을 하나로 그룹화합니다
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<Switch
|
|||
|
|
checked={grouping.enabled}
|
|||
|
|
onCheckedChange={(checked) =>
|
|||
|
|
setGrouping({ ...grouping, enabled: checked })
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{grouping.enabled && (
|
|||
|
|
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
|
|||
|
|
<div>
|
|||
|
|
<Label className="text-sm">그룹 기준 컬럼</Label>
|
|||
|
|
<SearchableColumnSelect
|
|||
|
|
tableName={tableName}
|
|||
|
|
value={grouping.groupByColumn}
|
|||
|
|
onValueChange={(value) =>
|
|||
|
|
setGrouping({ ...grouping, groupByColumn: value })
|
|||
|
|
}
|
|||
|
|
placeholder="그룹 기준 컬럼 선택"
|
|||
|
|
className="mt-1"
|
|||
|
|
/>
|
|||
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|||
|
|
예: item_id로 그룹핑하면 같은 품목의 데이터를 하나로 표시합니다
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</ScrollArea>
|
|||
|
|
</TabsContent>
|
|||
|
|
|
|||
|
|
{/* 검색 탭 */}
|
|||
|
|
<TabsContent value="search" className="mt-4 flex min-h-0 flex-1 flex-col overflow-hidden data-[state=inactive]:hidden">
|
|||
|
|
<ScrollArea className="min-h-0 flex-1">
|
|||
|
|
<div className="space-y-4 pr-4">
|
|||
|
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
|||
|
|
<div>
|
|||
|
|
<Label className="text-sm font-medium">검색 표시</Label>
|
|||
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|||
|
|
검색 입력창을 표시합니다
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<Switch checked={showSearch} onCheckedChange={setShowSearch} />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{showSearch && (
|
|||
|
|
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
|
|||
|
|
<div className="flex items-center justify-between">
|
|||
|
|
<Label className="text-sm">검색 대상 컬럼</Label>
|
|||
|
|
<Button size="sm" variant="ghost" onClick={handleAddSearchColumn}>
|
|||
|
|
<Plus className="mr-1 h-3 w-3" />
|
|||
|
|
추가
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{searchColumns.length === 0 ? (
|
|||
|
|
<div className="text-center py-4 text-muted-foreground text-sm border rounded-md">
|
|||
|
|
검색할 컬럼을 추가하세요
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
{searchColumns.map((searchCol, index) => (
|
|||
|
|
<div key={index} className="flex items-center gap-2">
|
|||
|
|
<SearchableColumnSelect
|
|||
|
|
tableName={tableName}
|
|||
|
|
value={searchCol.columnName}
|
|||
|
|
onValueChange={(value) => handleUpdateSearchColumn(index, value)}
|
|||
|
|
placeholder="검색 컬럼 선택"
|
|||
|
|
className="flex-1"
|
|||
|
|
/>
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="ghost"
|
|||
|
|
className="h-9 w-9 p-0 text-destructive"
|
|||
|
|
onClick={() => handleRemoveSearchColumn(index)}
|
|||
|
|
>
|
|||
|
|
×
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</ScrollArea>
|
|||
|
|
</TabsContent>
|
|||
|
|
</Tabs>
|
|||
|
|
|
|||
|
|
<DialogFooter className="mt-4 shrink-0">
|
|||
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|||
|
|
취소
|
|||
|
|
</Button>
|
|||
|
|
<Button onClick={handleSave}>저장</Button>
|
|||
|
|
</DialogFooter>
|
|||
|
|
</DialogContent>
|
|||
|
|
</Dialog>
|
|||
|
|
|
|||
|
|
{/* 컬럼 세부설정 모달 */}
|
|||
|
|
<Dialog open={detailModalOpen} onOpenChange={setDetailModalOpen}>
|
|||
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
|||
|
|
<DialogHeader>
|
|||
|
|
<DialogTitle>컬럼 세부설정</DialogTitle>
|
|||
|
|
<DialogDescription>
|
|||
|
|
{editingColumn?.label || editingColumn?.name || "컬럼"}의 표시 설정을 변경합니다.
|
|||
|
|
</DialogDescription>
|
|||
|
|
</DialogHeader>
|
|||
|
|
|
|||
|
|
{editingColumn && (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
{/* 기본 설정 */}
|
|||
|
|
<div className="space-y-3 p-3 border rounded-lg">
|
|||
|
|
<h4 className="text-sm font-medium">기본 설정</h4>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<Label className="text-xs">컬럼 선택</Label>
|
|||
|
|
<SearchableColumnSelect
|
|||
|
|
tableName={tableName}
|
|||
|
|
value={editingColumn.name}
|
|||
|
|
onValueChange={(value) => {
|
|||
|
|
const colInfo = columns.find((c) => c.column_name === value);
|
|||
|
|
setEditingColumn({
|
|||
|
|
...editingColumn,
|
|||
|
|
name: value,
|
|||
|
|
label: colInfo?.column_comment || editingColumn.label,
|
|||
|
|
});
|
|||
|
|
}}
|
|||
|
|
className="mt-1"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<Label className="text-xs">표시 라벨</Label>
|
|||
|
|
<Input
|
|||
|
|
value={editingColumn.label || ""}
|
|||
|
|
onChange={(e) =>
|
|||
|
|
setEditingColumn({ ...editingColumn, label: e.target.value })
|
|||
|
|
}
|
|||
|
|
placeholder="라벨명 (미입력 시 컬럼명 사용)"
|
|||
|
|
className="mt-1 h-9"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<Label className="text-xs">표시 위치</Label>
|
|||
|
|
<Select
|
|||
|
|
value={editingColumn.displayRow || "name"}
|
|||
|
|
onValueChange={(value: "name" | "info") =>
|
|||
|
|
setEditingColumn({ ...editingColumn, displayRow: value })
|
|||
|
|
}
|
|||
|
|
>
|
|||
|
|
<SelectTrigger className="mt-1 h-9">
|
|||
|
|
<SelectValue />
|
|||
|
|
</SelectTrigger>
|
|||
|
|
<SelectContent>
|
|||
|
|
<SelectItem value="name">이름 행 (Name Row)</SelectItem>
|
|||
|
|
<SelectItem value="info">정보 행 (Info Row)</SelectItem>
|
|||
|
|
</SelectContent>
|
|||
|
|
</Select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<Label className="text-xs">컬럼 너비 (px)</Label>
|
|||
|
|
<Input
|
|||
|
|
type="number"
|
|||
|
|
value={editingColumn.width || ""}
|
|||
|
|
onChange={(e) =>
|
|||
|
|
setEditingColumn({
|
|||
|
|
...editingColumn,
|
|||
|
|
width: e.target.value ? parseInt(e.target.value) : undefined,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
placeholder="자동"
|
|||
|
|
className="mt-1 h-9"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 그룹핑/집계 설정 (그룹핑 활성화 시만) */}
|
|||
|
|
{grouping.enabled && (
|
|||
|
|
<div className="space-y-3 p-3 border rounded-lg">
|
|||
|
|
<h4 className="text-sm font-medium">그룹핑/집계 설정</h4>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<Label className="text-xs">표시 방식</Label>
|
|||
|
|
<Select
|
|||
|
|
value={editingColumn.displayConfig?.displayType || "text"}
|
|||
|
|
onValueChange={(value: "text" | "badge") =>
|
|||
|
|
setEditingColumn({
|
|||
|
|
...editingColumn,
|
|||
|
|
displayConfig: {
|
|||
|
|
...editingColumn.displayConfig,
|
|||
|
|
displayType: value,
|
|||
|
|
} as ColumnDisplayConfig,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
>
|
|||
|
|
<SelectTrigger className="mt-1 h-9">
|
|||
|
|
<SelectValue />
|
|||
|
|
</SelectTrigger>
|
|||
|
|
<SelectContent>
|
|||
|
|
<SelectItem value="text">텍스트 (기본)</SelectItem>
|
|||
|
|
<SelectItem value="badge">배지 (태그 형태)</SelectItem>
|
|||
|
|
</SelectContent>
|
|||
|
|
</Select>
|
|||
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|||
|
|
배지는 여러 값을 태그 형태로 나란히 표시합니다
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex items-center justify-between">
|
|||
|
|
<div>
|
|||
|
|
<Label className="text-xs">집계 사용</Label>
|
|||
|
|
<p className="text-[10px] text-muted-foreground">
|
|||
|
|
그룹핑 시 값을 집계합니다
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<Switch
|
|||
|
|
checked={editingColumn.displayConfig?.aggregate?.enabled || false}
|
|||
|
|
onCheckedChange={(checked) =>
|
|||
|
|
setEditingColumn({
|
|||
|
|
...editingColumn,
|
|||
|
|
displayConfig: {
|
|||
|
|
displayType: editingColumn.displayConfig?.displayType || "text",
|
|||
|
|
aggregate: {
|
|||
|
|
enabled: checked,
|
|||
|
|
function: editingColumn.displayConfig?.aggregate?.function || "DISTINCT",
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{editingColumn.displayConfig?.aggregate?.enabled && (
|
|||
|
|
<div>
|
|||
|
|
<Label className="text-xs">집계 방식</Label>
|
|||
|
|
<Select
|
|||
|
|
value={editingColumn.displayConfig?.aggregate?.function || "DISTINCT"}
|
|||
|
|
onValueChange={(value: "DISTINCT" | "COUNT") =>
|
|||
|
|
setEditingColumn({
|
|||
|
|
...editingColumn,
|
|||
|
|
displayConfig: {
|
|||
|
|
displayType: editingColumn.displayConfig?.displayType || "text",
|
|||
|
|
aggregate: {
|
|||
|
|
enabled: true,
|
|||
|
|
function: value,
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
>
|
|||
|
|
<SelectTrigger className="mt-1 h-9">
|
|||
|
|
<SelectValue />
|
|||
|
|
</SelectTrigger>
|
|||
|
|
<SelectContent>
|
|||
|
|
<SelectItem value="DISTINCT">중복제거 (고유값만)</SelectItem>
|
|||
|
|
<SelectItem value="COUNT">개수</SelectItem>
|
|||
|
|
</SelectContent>
|
|||
|
|
</Select>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 엔티티 참조 설정 (entity 타입 컬럼일 때만 표시) */}
|
|||
|
|
{(() => {
|
|||
|
|
const entityInfo = getEditingColumnEntityInfo();
|
|||
|
|
if (!entityInfo) return null;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-3 p-3 border rounded-lg">
|
|||
|
|
<h4 className="text-sm font-medium">엔티티 표시 컬럼</h4>
|
|||
|
|
<p className="text-xs text-muted-foreground">
|
|||
|
|
참조 테이블: <span className="font-medium">{entityInfo.referenceTable}</span>
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
{entityInfo.isLoading ? (
|
|||
|
|
<div className="flex items-center justify-center py-4">
|
|||
|
|
<span className="text-sm text-muted-foreground">컬럼 정보 로딩 중...</span>
|
|||
|
|
</div>
|
|||
|
|
) : entityInfo.referenceColumns.length === 0 ? (
|
|||
|
|
<div className="text-center py-4 text-muted-foreground text-sm border rounded-md">
|
|||
|
|
참조 테이블의 컬럼 정보를 불러올 수 없습니다
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<ScrollArea className="max-h-40">
|
|||
|
|
<div className="space-y-2 pr-4">
|
|||
|
|
{entityInfo.referenceColumns.map((col) => {
|
|||
|
|
const isSelected = (editingColumn.entityReference?.displayColumns || []).includes(col.columnName);
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
key={col.columnName}
|
|||
|
|
className={cn(
|
|||
|
|
"flex items-center gap-2 p-2 rounded-md cursor-pointer hover:bg-muted/50 transition-colors",
|
|||
|
|
isSelected && "bg-muted"
|
|||
|
|
)}
|
|||
|
|
onClick={() => toggleEntityDisplayColumn(col.columnName)}
|
|||
|
|
>
|
|||
|
|
<Checkbox
|
|||
|
|
checked={isSelected}
|
|||
|
|
onCheckedChange={() => toggleEntityDisplayColumn(col.columnName)}
|
|||
|
|
/>
|
|||
|
|
<div className="flex-1 min-w-0">
|
|||
|
|
<span className="text-sm font-medium truncate block">
|
|||
|
|
{col.displayName || col.columnName}
|
|||
|
|
</span>
|
|||
|
|
<span className="text-xs text-muted-foreground truncate block">
|
|||
|
|
{col.columnName} ({col.dataType})
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</ScrollArea>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{(editingColumn.entityReference?.displayColumns || []).length > 0 && (
|
|||
|
|
<div className="mt-2 pt-2 border-t">
|
|||
|
|
<p className="text-xs text-muted-foreground mb-1">
|
|||
|
|
선택됨: {(editingColumn.entityReference?.displayColumns || []).length}개
|
|||
|
|
</p>
|
|||
|
|
<div className="flex flex-wrap gap-1">
|
|||
|
|
{(editingColumn.entityReference?.displayColumns || []).map((colName) => {
|
|||
|
|
const colInfo = entityInfo.referenceColumns.find(c => c.columnName === colName);
|
|||
|
|
return (
|
|||
|
|
<span
|
|||
|
|
key={colName}
|
|||
|
|
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary"
|
|||
|
|
>
|
|||
|
|
{colInfo?.displayName || colName}
|
|||
|
|
</span>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})()}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<DialogFooter>
|
|||
|
|
<Button variant="outline" onClick={() => setDetailModalOpen(false)}>
|
|||
|
|
취소
|
|||
|
|
</Button>
|
|||
|
|
<Button onClick={handleSaveDetailSettings}>적용</Button>
|
|||
|
|
</DialogFooter>
|
|||
|
|
</DialogContent>
|
|||
|
|
</Dialog>
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export default ColumnConfigModal;
|
|||
|
|
|