ERP-node/frontend/lib/registry/components/split-panel-layout2/ColumnConfigModal.tsx

806 lines
32 KiB
TypeScript
Raw Normal View History

"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;