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

807 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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