From 3396834417d1be89006b1f7155dfabed5bde0cfb Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 24 Dec 2025 09:08:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(split-panel-layout2):=20=EA=B7=B8=EB=A3=B9?= =?UTF-8?q?=ED=95=91,=20=ED=83=AD=20=ED=95=84=ED=84=B0=EB=A7=81,=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=AA=A8=EB=8B=AC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts: GroupingConfig, TabConfig, ColumnDisplayConfig 등 타입 확장 - Component: groupData, generateTabs, filterDataByTab 함수 추가 - ConfigPanel: SearchableColumnSelect, 설정 모달 상태 관리 추가 - 신규 모달: ActionButtonConfigModal, ColumnConfigModal, DataTransferConfigModal - UniversalFormModal: 연결필드 소스 테이블 Combobox로 변경 --- .../ActionButtonConfigModal.tsx | 674 +++++++ .../split-panel-layout2/ColumnConfigModal.tsx | 805 ++++++++ .../DataTransferConfigModal.tsx | 423 ++++ .../SplitPanelLayout2Component.tsx | 821 +++++++- .../SplitPanelLayout2ConfigPanel.tsx | 1734 +++++++++-------- .../components/SearchableColumnSelect.tsx | 163 ++ .../components/SortableColumnItem.tsx | 118 ++ .../components/split-panel-layout2/index.ts | 8 + .../components/split-panel-layout2/types.ts | 121 +- .../UniversalFormModalConfigPanel.tsx | 2 + .../modals/FieldDetailSettingsModal.tsx | 189 +- 11 files changed, 4189 insertions(+), 869 deletions(-) create mode 100644 frontend/lib/registry/components/split-panel-layout2/ActionButtonConfigModal.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout2/ColumnConfigModal.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout2/DataTransferConfigModal.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout2/components/SearchableColumnSelect.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout2/components/SortableColumnItem.tsx diff --git a/frontend/lib/registry/components/split-panel-layout2/ActionButtonConfigModal.tsx b/frontend/lib/registry/components/split-panel-layout2/ActionButtonConfigModal.tsx new file mode 100644 index 00000000..aeff27c2 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/ActionButtonConfigModal.tsx @@ -0,0 +1,674 @@ +"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, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Plus, GripVertical, Settings, X, Check, ChevronsUpDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import type { ActionButtonConfig, ModalParamMapping, ColumnConfig } from "./types"; + +interface ScreenInfo { + screen_id: number; + screen_name: string; + screen_code: string; +} + +// 정렬 가능한 버튼 아이템 +const SortableButtonItem: React.FC<{ + id: string; + button: ActionButtonConfig; + index: number; + onSettingsClick: () => void; + onRemove: () => void; +}> = ({ id, button, index, onSettingsClick, onRemove }) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const getVariantColor = (variant?: string) => { + switch (variant) { + case "destructive": + return "bg-destructive/10 text-destructive"; + case "outline": + return "bg-background border"; + case "ghost": + return "bg-muted/50"; + case "secondary": + return "bg-secondary text-secondary-foreground"; + default: + return "bg-primary/10 text-primary"; + } + }; + + const getActionLabel = (action?: string) => { + switch (action) { + case "add": + return "추가"; + case "edit": + return "수정"; + case "delete": + return "삭제"; + case "bulk-delete": + return "일괄삭제"; + case "api": + return "API"; + case "custom": + return "커스텀"; + default: + return "추가"; + } + }; + + return ( +
+ {/* 드래그 핸들 */} +
+ +
+ + {/* 버튼 정보 */} +
+
+ + {button.label || `버튼 ${index + 1}`} + +
+
+ + {getActionLabel(button.action)} + + {button.icon && ( + + {button.icon} + + )} + {button.showCondition && button.showCondition !== "always" && ( + + {button.showCondition === "selected" ? "선택시만" : "미선택시만"} + + )} +
+
+ + {/* 액션 버튼들 */} +
+ + +
+
+ ); +}; + +interface ActionButtonConfigModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + actionButtons: ActionButtonConfig[]; + displayColumns?: ColumnConfig[]; // 모달 파라미터 매핑용 + onSave: (buttons: ActionButtonConfig[]) => void; + side: "left" | "right"; +} + +export const ActionButtonConfigModal: React.FC = ({ + open, + onOpenChange, + actionButtons: initialButtons, + displayColumns = [], + onSave, + side, +}) => { + // 로컬 상태 + const [buttons, setButtons] = useState([]); + + // 버튼 세부설정 모달 + const [detailModalOpen, setDetailModalOpen] = useState(false); + const [editingButtonIndex, setEditingButtonIndex] = useState(null); + const [editingButton, setEditingButton] = useState(null); + + // 화면 목록 + const [screens, setScreens] = useState([]); + const [screensLoading, setScreensLoading] = useState(false); + const [screenSelectOpen, setScreenSelectOpen] = useState(false); + + // 드래그 센서 + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + // 초기값 설정 + useEffect(() => { + if (open) { + setButtons(initialButtons || []); + } + }, [open, initialButtons]); + + // 화면 목록 로드 + const loadScreens = useCallback(async () => { + setScreensLoading(true); + try { + const response = await apiClient.get("/screen-management/screens?size=1000"); + + let screenList: any[] = []; + if (response.data?.success && Array.isArray(response.data?.data)) { + screenList = response.data.data; + } else if (Array.isArray(response.data?.data)) { + screenList = response.data.data; + } + + const transformedScreens = screenList.map((s: any) => ({ + screen_id: s.screenId ?? s.screen_id ?? s.id, + screen_name: s.screenName ?? s.screen_name ?? s.name ?? `화면 ${s.screenId || s.screen_id || s.id}`, + screen_code: s.screenCode ?? s.screen_code ?? s.code ?? "", + })); + + setScreens(transformedScreens); + } catch (error) { + console.error("화면 목록 로드 실패:", error); + setScreens([]); + } finally { + setScreensLoading(false); + } + }, []); + + useEffect(() => { + if (open) { + loadScreens(); + } + }, [open, loadScreens]); + + // 드래그 종료 핸들러 + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + const oldIndex = buttons.findIndex((btn) => btn.id === active.id); + const newIndex = buttons.findIndex((btn) => btn.id === over.id); + + if (oldIndex !== -1 && newIndex !== -1) { + setButtons(arrayMove(buttons, oldIndex, newIndex)); + } + } + }; + + // 버튼 추가 + const handleAddButton = () => { + const newButton: ActionButtonConfig = { + id: `btn-${Date.now()}`, + label: "새 버튼", + variant: "default", + action: "add", + showCondition: "always", + }; + setButtons([...buttons, newButton]); + }; + + // 버튼 삭제 + const handleRemoveButton = (index: number) => { + setButtons(buttons.filter((_, i) => i !== index)); + }; + + // 버튼 업데이트 + const handleUpdateButton = (index: number, updates: Partial) => { + const newButtons = [...buttons]; + newButtons[index] = { ...newButtons[index], ...updates }; + setButtons(newButtons); + }; + + // 버튼 세부설정 열기 + const handleOpenDetailSettings = (index: number) => { + setEditingButtonIndex(index); + setEditingButton({ ...buttons[index] }); + setDetailModalOpen(true); + }; + + // 버튼 세부설정 저장 + const handleSaveDetailSettings = () => { + if (editingButtonIndex !== null && editingButton) { + handleUpdateButton(editingButtonIndex, editingButton); + } + setDetailModalOpen(false); + setEditingButtonIndex(null); + setEditingButton(null); + }; + + // 저장 + const handleSave = () => { + onSave(buttons); + onOpenChange(false); + }; + + // 선택된 화면 정보 + const getScreenInfo = (screenId?: number) => { + return screens.find((s) => s.screen_id === screenId); + }; + + return ( + <> + + + + + {side === "left" ? "좌측" : "우측"} 패널 액션 버튼 설정 + + + 버튼을 추가하고 순서를 드래그로 변경할 수 있습니다. + + + +
+
+ + +
+ + + {buttons.length === 0 ? ( +
+

+ 액션 버튼이 없습니다 +

+ +
+ ) : ( + + btn.id)} + strategy={verticalListSortingStrategy} + > +
+ {buttons.map((btn, index) => ( + handleOpenDetailSettings(index)} + onRemove={() => handleRemoveButton(index)} + /> + ))} +
+
+
+ )} +
+
+ + + + + +
+
+ + {/* 버튼 세부설정 모달 */} + + + + 버튼 세부설정 + + {editingButton?.label || "버튼"}의 동작을 설정합니다. + + + + {editingButton && ( + +
+ {/* 기본 설정 */} +
+

기본 설정

+ +
+ + + setEditingButton({ ...editingButton, label: e.target.value }) + } + placeholder="버튼 라벨" + className="mt-1 h-9" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {/* 동작 설정 */} +
+

동작 설정

+ +
+ + +
+ + {/* 모달 설정 (add, edit 액션) */} + {(editingButton.action === "add" || editingButton.action === "edit") && ( +
+ + + + + + + + + + 검색 결과가 없습니다 + + {screens.map((screen) => ( + { + setEditingButton({ + ...editingButton, + modalScreenId: screen.screen_id, + }); + setScreenSelectOpen(false); + }} + > + + + {screen.screen_name} + + {screen.screen_code} + + + + ))} + + + + + +
+ )} + + {/* API 설정 */} + {editingButton.action === "api" && ( + <> +
+ + + setEditingButton({ + ...editingButton, + apiEndpoint: e.target.value, + }) + } + placeholder="/api/example" + className="mt-1 h-9" + /> +
+
+ + +
+ + )} + + {/* 확인 메시지 (삭제 계열) */} + {(editingButton.action === "delete" || + editingButton.action === "bulk-delete" || + (editingButton.action === "api" && editingButton.apiMethod === "DELETE")) && ( +
+ + + setEditingButton({ + ...editingButton, + confirmMessage: e.target.value, + }) + } + placeholder="정말 삭제하시겠습니까?" + className="mt-1 h-9" + /> +
+ )} + + {/* 커스텀 액션 ID */} + {editingButton.action === "custom" && ( +
+ + + setEditingButton({ + ...editingButton, + customActionId: e.target.value, + }) + } + placeholder="customAction1" + className="mt-1 h-9" + /> +

+ 커스텀 이벤트 핸들러에서 이 ID로 버튼을 구분합니다 +

+
+ )} +
+ +
+
+ )} + + + + + +
+
+ + ); +}; + +export default ActionButtonConfigModal; + diff --git a/frontend/lib/registry/components/split-panel-layout2/ColumnConfigModal.tsx b/frontend/lib/registry/components/split-panel-layout2/ColumnConfigModal.tsx new file mode 100644 index 00000000..89866651 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/ColumnConfigModal.tsx @@ -0,0 +1,805 @@ +"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 = ({ + open, + onOpenChange, + tableName, + displayColumns: initialDisplayColumns, + searchColumns: initialSearchColumns, + grouping: initialGrouping, + showSearch: initialShowSearch, + onSave, + side, +}) => { + // 로컬 상태 (모달 내에서만 사용, 저장 시 부모로 전달) + const [displayColumns, setDisplayColumns] = useState([]); + const [searchColumns, setSearchColumns] = useState([]); + const [grouping, setGrouping] = useState({ enabled: false, groupByColumn: "" }); + const [showSearch, setShowSearch] = useState(false); + + // 컬럼 세부설정 모달 + const [detailModalOpen, setDetailModalOpen] = useState(false); + const [editingColumnIndex, setEditingColumnIndex] = useState(null); + const [editingColumn, setEditingColumn] = useState(null); + + // 테이블 컬럼 목록 + const [columns, setColumns] = useState([]); + const [columnsLoading, setColumnsLoading] = useState(false); + + // 엔티티 참조 관련 상태 + const [entityReferenceColumns, setEntityReferenceColumns] = useState>(new Map()); + const [loadingEntityColumns, setLoadingEntityColumns] = useState>(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) => { + 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 ( + <> + + + + + + {side === "left" ? "좌측" : "우측"} 패널 컬럼 설정 + + + 표시할 컬럼을 추가하고 순서를 드래그로 변경할 수 있습니다. + + + + + + 표시 컬럼 + + 그룹핑 + + 검색 + + + {/* 표시 컬럼 탭 */} + +
+ + +
+ + +
+ {displayColumns.length === 0 ? ( +
+

+ 표시할 컬럼이 없습니다 +

+ +
+ ) : ( + + `col-${idx}`)} + strategy={verticalListSortingStrategy} + > +
+ {displayColumns.map((col, index) => ( +
+ handleOpenDetailSettings(index)} + onRemove={() => handleRemoveColumn(index)} + showGroupingSettings={grouping.enabled} + /> + {/* 컬럼 빠른 선택 (인라인) */} + {!col.name && ( +
+ { + const colInfo = columns.find((c) => c.column_name === value); + handleUpdateColumn(index, { + name: value, + label: colInfo?.column_comment || "", + }); + }} + excludeColumns={selectedColumnNames} + placeholder="컬럼을 선택하세요" + /> +
+ )} +
+ ))} +
+
+
+ )} +
+
+
+ + {/* 그룹핑 탭 (좌측 패널만) */} + + +
+
+
+ +

+ 동일한 값을 가진 행들을 하나로 그룹화합니다 +

+
+ + setGrouping({ ...grouping, enabled: checked }) + } + /> +
+ + {grouping.enabled && ( +
+
+ + + setGrouping({ ...grouping, groupByColumn: value }) + } + placeholder="그룹 기준 컬럼 선택" + className="mt-1" + /> +

+ 예: item_id로 그룹핑하면 같은 품목의 데이터를 하나로 표시합니다 +

+
+
+ )} +
+
+
+ + {/* 검색 탭 */} + + +
+
+
+ +

+ 검색 입력창을 표시합니다 +

+
+ +
+ + {showSearch && ( +
+
+ + +
+ + {searchColumns.length === 0 ? ( +
+ 검색할 컬럼을 추가하세요 +
+ ) : ( +
+ {searchColumns.map((searchCol, index) => ( +
+ handleUpdateSearchColumn(index, value)} + placeholder="검색 컬럼 선택" + className="flex-1" + /> + +
+ ))} +
+ )} +
+ )} +
+
+
+
+ + + + + +
+
+ + {/* 컬럼 세부설정 모달 */} + + + + 컬럼 세부설정 + + {editingColumn?.label || editingColumn?.name || "컬럼"}의 표시 설정을 변경합니다. + + + + {editingColumn && ( +
+ {/* 기본 설정 */} +
+

기본 설정

+ +
+ + { + const colInfo = columns.find((c) => c.column_name === value); + setEditingColumn({ + ...editingColumn, + name: value, + label: colInfo?.column_comment || editingColumn.label, + }); + }} + className="mt-1" + /> +
+ +
+ + + setEditingColumn({ ...editingColumn, label: e.target.value }) + } + placeholder="라벨명 (미입력 시 컬럼명 사용)" + className="mt-1 h-9" + /> +
+ +
+ + +
+ +
+ + + setEditingColumn({ + ...editingColumn, + width: e.target.value ? parseInt(e.target.value) : undefined, + }) + } + placeholder="자동" + className="mt-1 h-9" + /> +
+
+ + {/* 그룹핑/집계 설정 (그룹핑 활성화 시만) */} + {grouping.enabled && ( +
+

그룹핑/집계 설정

+ +
+ + +

+ 배지는 여러 값을 태그 형태로 나란히 표시합니다 +

+
+ +
+
+ +

+ 그룹핑 시 값을 집계합니다 +

+
+ + setEditingColumn({ + ...editingColumn, + displayConfig: { + displayType: editingColumn.displayConfig?.displayType || "text", + aggregate: { + enabled: checked, + function: editingColumn.displayConfig?.aggregate?.function || "DISTINCT", + }, + }, + }) + } + /> +
+ + {editingColumn.displayConfig?.aggregate?.enabled && ( +
+ + +
+ )} +
+ )} + + {/* 엔티티 참조 설정 (entity 타입 컬럼일 때만 표시) */} + {(() => { + const entityInfo = getEditingColumnEntityInfo(); + if (!entityInfo) return null; + + return ( +
+

엔티티 표시 컬럼

+

+ 참조 테이블: {entityInfo.referenceTable} +

+ + {entityInfo.isLoading ? ( +
+ 컬럼 정보 로딩 중... +
+ ) : entityInfo.referenceColumns.length === 0 ? ( +
+ 참조 테이블의 컬럼 정보를 불러올 수 없습니다 +
+ ) : ( + +
+ {entityInfo.referenceColumns.map((col) => { + const isSelected = (editingColumn.entityReference?.displayColumns || []).includes(col.columnName); + return ( +
toggleEntityDisplayColumn(col.columnName)} + > + toggleEntityDisplayColumn(col.columnName)} + /> +
+ + {col.displayName || col.columnName} + + + {col.columnName} ({col.dataType}) + +
+
+ ); + })} +
+
+ )} + + {(editingColumn.entityReference?.displayColumns || []).length > 0 && ( +
+

+ 선택됨: {(editingColumn.entityReference?.displayColumns || []).length}개 +

+
+ {(editingColumn.entityReference?.displayColumns || []).map((colName) => { + const colInfo = entityInfo.referenceColumns.find(c => c.columnName === colName); + return ( + + {colInfo?.displayName || colName} + + ); + })} +
+
+ )} +
+ ); + })()} +
+ )} + + + + + +
+
+ + ); +}; + +export default ColumnConfigModal; + diff --git a/frontend/lib/registry/components/split-panel-layout2/DataTransferConfigModal.tsx b/frontend/lib/registry/components/split-panel-layout2/DataTransferConfigModal.tsx new file mode 100644 index 00000000..36ce4a96 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/DataTransferConfigModal.tsx @@ -0,0 +1,423 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Plus, X, Settings, ArrowRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +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 { Badge } from "@/components/ui/badge"; +import type { DataTransferField } from "./types"; + +interface ColumnInfo { + column_name: string; + column_comment?: string; + data_type?: string; +} + +interface DataTransferConfigModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + dataTransferFields: DataTransferField[]; + onChange: (fields: DataTransferField[]) => void; + leftColumns: ColumnInfo[]; + rightColumns: ColumnInfo[]; + leftTableName?: string; + rightTableName?: string; +} + +// 컬럼 선택 컴포넌트 +const ColumnSelect: React.FC<{ + columns: ColumnInfo[]; + value: string; + onValueChange: (value: string) => void; + placeholder: string; + disabled?: boolean; +}> = ({ columns, value, onValueChange, placeholder, disabled = false }) => { + return ( + + ); +}; + +// 개별 필드 편집 모달 +const FieldEditModal: React.FC<{ + open: boolean; + onOpenChange: (open: boolean) => void; + field: DataTransferField | null; + onSave: (field: DataTransferField) => void; + leftColumns: ColumnInfo[]; + rightColumns: ColumnInfo[]; + leftTableName?: string; + rightTableName?: string; + isNew?: boolean; +}> = ({ + open, + onOpenChange, + field, + onSave, + leftColumns, + rightColumns, + leftTableName, + rightTableName, + isNew = false, +}) => { + const [editingField, setEditingField] = useState({ + id: "", + panel: "left", + sourceColumn: "", + targetColumn: "", + label: "", + description: "", + }); + + useEffect(() => { + if (field) { + setEditingField({ ...field }); + } else { + setEditingField({ + id: `field_${Date.now()}`, + panel: "left", + sourceColumn: "", + targetColumn: "", + label: "", + description: "", + }); + } + }, [field, open]); + + const handleSave = () => { + if (!editingField.sourceColumn || !editingField.targetColumn) { + return; + } + onSave(editingField); + onOpenChange(false); + }; + + const currentColumns = editingField.panel === "left" ? leftColumns : rightColumns; + const currentTableName = editingField.panel === "left" ? leftTableName : rightTableName; + + return ( + + + + {isNew ? "데이터 전달 필드 추가" : "데이터 전달 필드 편집"} + + 선택한 항목의 데이터를 모달에 자동으로 전달합니다. + + + +
+ {/* 패널 선택 */} +
+ + +

데이터를 가져올 패널을 선택합니다.

+
+ + {/* 소스 컬럼 */} +
+ +
+ { + const col = currentColumns.find((c) => c.column_name === value); + setEditingField({ + ...editingField, + sourceColumn: value, + // 타겟 컬럼이 비어있으면 소스와 동일하게 설정 + targetColumn: editingField.targetColumn || value, + // 라벨이 비어있으면 컬럼 코멘트 사용 + label: editingField.label || col?.column_comment || "", + }); + }} + placeholder="컬럼 선택..." + disabled={currentColumns.length === 0} + /> +
+ {currentColumns.length === 0 && ( +

+ {currentTableName ? "테이블에 컬럼이 없습니다." : "테이블을 먼저 선택해주세요."} +

+ )} +
+ + {/* 타겟 컬럼 */} +
+ + setEditingField({ ...editingField, targetColumn: e.target.value })} + placeholder="모달에서 사용할 필드명" + className="mt-1 h-9 text-sm" + /> +

모달 폼에서 이 값을 받을 필드명입니다.

+
+ + {/* 라벨 (선택) */} +
+ + setEditingField({ ...editingField, label: e.target.value })} + placeholder="표시용 이름" + className="mt-1 h-9 text-sm" + /> +
+ + {/* 설명 (선택) */} +
+ + setEditingField({ ...editingField, description: e.target.value })} + placeholder="이 필드에 대한 설명" + className="mt-1 h-9 text-sm" + /> +
+ + {/* 미리보기 */} + {editingField.sourceColumn && editingField.targetColumn && ( +
+

미리보기

+
+ + {editingField.panel === "left" ? "좌측" : "우측"} + + {editingField.sourceColumn} + + {editingField.targetColumn} +
+
+ )} +
+ + + + + +
+
+ ); +}; + +// 메인 모달 컴포넌트 +const DataTransferConfigModal: React.FC = ({ + open, + onOpenChange, + dataTransferFields, + onChange, + leftColumns, + rightColumns, + leftTableName, + rightTableName, +}) => { + const [fields, setFields] = useState([]); + const [editModalOpen, setEditModalOpen] = useState(false); + const [editingField, setEditingField] = useState(null); + const [isNewField, setIsNewField] = useState(false); + + useEffect(() => { + if (open) { + // 기존 필드에 panel이 없으면 left로 기본 설정 (하위 호환성) + const normalizedFields = (dataTransferFields || []).map((field, idx) => ({ + ...field, + id: field.id || `field_${idx}`, + panel: field.panel || ("left" as const), + })); + setFields(normalizedFields); + } + }, [open, dataTransferFields]); + + const handleAddField = () => { + setEditingField(null); + setIsNewField(true); + setEditModalOpen(true); + }; + + const handleEditField = (field: DataTransferField) => { + setEditingField(field); + setIsNewField(false); + setEditModalOpen(true); + }; + + const handleSaveField = (field: DataTransferField) => { + if (isNewField) { + setFields([...fields, field]); + } else { + setFields(fields.map((f) => (f.id === field.id ? field : f))); + } + }; + + const handleRemoveField = (id: string) => { + setFields(fields.filter((f) => f.id !== id)); + }; + + const handleSave = () => { + onChange(fields); + onOpenChange(false); + }; + + const getColumnLabel = (panel: "left" | "right", columnName: string) => { + const columns = panel === "left" ? leftColumns : rightColumns; + const col = columns.find((c) => c.column_name === columnName); + return col?.column_comment || columnName; + }; + + return ( + <> + + + + 데이터 전달 설정 + + 버튼 클릭 시 모달에 자동으로 전달할 데이터를 설정합니다. + + + +
+
+ 전달 필드 ({fields.length}개) + +
+ + +
+ {fields.length === 0 ? ( +
+

전달할 필드가 없습니다

+ +
+ ) : ( + fields.map((field) => ( +
+ + {field.panel === "left" ? "좌측" : "우측"} + +
+
+ {getColumnLabel(field.panel, field.sourceColumn)} + + {field.targetColumn} +
+ {field.description && ( +

{field.description}

+ )} +
+
+ + +
+
+ )) + )} +
+
+
+ +
+

버튼별로 개별 데이터 전달 설정이 있으면 해당 설정이 우선 적용됩니다.

+
+ + + + + +
+
+ + {/* 필드 편집 모달 */} + + + ); +}; + +export default DataTransferConfigModal; diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index 679a64c9..ec52ba80 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -2,7 +2,8 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { ComponentRendererProps } from "@/types/component"; -import { SplitPanelLayout2Config, ColumnConfig, DataTransferField, ActionButtonConfig, JoinTableConfig } from "./types"; +import { SplitPanelLayout2Config, ColumnConfig, DataTransferField, ActionButtonConfig, JoinTableConfig, GroupingConfig, ColumnDisplayConfig, TabConfig } from "./types"; +import { Badge } from "@/components/ui/badge"; import { defaultConfig } from "./config"; import { cn } from "@/lib/utils"; import { @@ -86,6 +87,177 @@ export const SplitPanelLayout2Component: React.FC(null); const [isBulkDelete, setIsBulkDelete] = useState(false); + // 탭 상태 (좌측/우측 각각) + const [leftActiveTab, setLeftActiveTab] = useState(null); + const [rightActiveTab, setRightActiveTab] = useState(null); + + // 프론트엔드 그룹핑 함수 + const groupData = useCallback( + (data: Record[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record[] => { + if (!groupingConfig.enabled || !groupingConfig.groupByColumn) { + return data; + } + + const groupByColumn = groupingConfig.groupByColumn; + const groupMap = new Map>(); + + // 데이터를 그룹별로 수집 + data.forEach((item) => { + const groupKey = String(item[groupByColumn] ?? ""); + + if (!groupMap.has(groupKey)) { + // 첫 번째 항목을 기준으로 그룹 초기화 + const groupedItem: Record = { ...item }; + + // 각 컬럼의 displayConfig 확인하여 집계 준비 + columns.forEach((col) => { + if (col.displayConfig?.aggregate?.enabled) { + // 집계가 활성화된 컬럼은 배열로 초기화 + groupedItem[`__agg_${col.name}`] = [item[col.name]]; + } + }); + + groupMap.set(groupKey, groupedItem); + } else { + // 기존 그룹에 값 추가 + const existingGroup = groupMap.get(groupKey)!; + + columns.forEach((col) => { + if (col.displayConfig?.aggregate?.enabled) { + const aggKey = `__agg_${col.name}`; + if (!existingGroup[aggKey]) { + existingGroup[aggKey] = []; + } + existingGroup[aggKey].push(item[col.name]); + } + }); + } + }); + + // 집계 처리 및 결과 변환 + const result: Record[] = []; + + groupMap.forEach((groupedItem) => { + columns.forEach((col) => { + if (col.displayConfig?.aggregate?.enabled) { + const aggKey = `__agg_${col.name}`; + const values = groupedItem[aggKey] || []; + + if (col.displayConfig.aggregate.function === "DISTINCT") { + // 중복 제거 후 배열로 저장 + const uniqueValues = [...new Set(values.filter((v: any) => v !== null && v !== undefined))]; + groupedItem[col.name] = uniqueValues; + } else if (col.displayConfig.aggregate.function === "COUNT") { + // 개수를 숫자로 저장 + groupedItem[col.name] = values.filter((v: any) => v !== null && v !== undefined).length; + } + + // 임시 집계 키 제거 + delete groupedItem[aggKey]; + } + }); + + result.push(groupedItem); + }); + + console.log(`[SplitPanelLayout2] 그룹핑 완료: ${data.length}건 → ${result.length}개 그룹`); + return result; + }, + [], + ); + + // 탭 목록 생성 함수 (데이터에서 고유값 추출) + const generateTabs = useCallback( + (data: Record[], tabConfig: TabConfig | undefined): { id: string; label: string; count: number }[] => { + if (!tabConfig?.enabled || !tabConfig.tabSourceColumn) { + return []; + } + + const sourceColumn = tabConfig.tabSourceColumn; + + // 데이터에서 고유값 추출 및 개수 카운트 + const valueCount = new Map(); + data.forEach((item) => { + const value = String(item[sourceColumn] ?? ""); + if (value) { + valueCount.set(value, (valueCount.get(value) || 0) + 1); + } + }); + + // 탭 목록 생성 + const tabs = Array.from(valueCount.entries()).map(([value, count]) => ({ + id: value, + label: value, + count: tabConfig.showCount ? count : 0, + })); + + console.log(`[SplitPanelLayout2] 탭 생성: ${tabs.length}개 탭 (컬럼: ${sourceColumn})`); + return tabs; + }, + [], + ); + + // 탭으로 필터링된 데이터 반환 + const filterDataByTab = useCallback( + (data: Record[], activeTab: string | null, tabConfig: TabConfig | undefined): Record[] => { + if (!tabConfig?.enabled || !activeTab || !tabConfig.tabSourceColumn) { + return data; + } + + const sourceColumn = tabConfig.tabSourceColumn; + return data.filter((item) => String(item[sourceColumn] ?? "") === activeTab); + }, + [], + ); + + // 좌측 패널 탭 목록 (메모이제이션) + const leftTabs = useMemo(() => { + if (!config.leftPanel?.tabConfig?.enabled || !config.leftPanel?.tabConfig?.tabSourceColumn) { + return []; + } + return generateTabs(leftData, config.leftPanel.tabConfig); + }, [leftData, config.leftPanel?.tabConfig, generateTabs]); + + // 우측 패널 탭 목록 (메모이제이션) + const rightTabs = useMemo(() => { + if (!config.rightPanel?.tabConfig?.enabled || !config.rightPanel?.tabConfig?.tabSourceColumn) { + return []; + } + return generateTabs(rightData, config.rightPanel.tabConfig); + }, [rightData, config.rightPanel?.tabConfig, generateTabs]); + + // 탭 기본값 설정 (탭 목록이 변경되면 기본 탭 선택) + useEffect(() => { + if (leftTabs.length > 0 && !leftActiveTab) { + const defaultTab = config.leftPanel?.tabConfig?.defaultTab; + if (defaultTab && leftTabs.some((t) => t.id === defaultTab)) { + setLeftActiveTab(defaultTab); + } else { + setLeftActiveTab(leftTabs[0].id); + } + } + }, [leftTabs, leftActiveTab, config.leftPanel?.tabConfig?.defaultTab]); + + useEffect(() => { + if (rightTabs.length > 0 && !rightActiveTab) { + const defaultTab = config.rightPanel?.tabConfig?.defaultTab; + if (defaultTab && rightTabs.some((t) => t.id === defaultTab)) { + setRightActiveTab(defaultTab); + } else { + setRightActiveTab(rightTabs[0].id); + } + } + }, [rightTabs, rightActiveTab, config.rightPanel?.tabConfig?.defaultTab]); + + // 탭 필터링된 데이터 (메모이제이션) + const filteredLeftDataByTab = useMemo(() => { + return filterDataByTab(leftData, leftActiveTab, config.leftPanel?.tabConfig); + }, [leftData, leftActiveTab, config.leftPanel?.tabConfig, filterDataByTab]); + + const filteredRightDataByTab = useMemo(() => { + return filterDataByTab(rightData, rightActiveTab, config.rightPanel?.tabConfig); + }, [rightData, rightActiveTab, config.rightPanel?.tabConfig, filterDataByTab]); + // 좌측 데이터 로드 const loadLeftData = useCallback(async () => { if (!config.leftPanel?.tableName || isDesignMode) return; @@ -115,6 +287,80 @@ export const SplitPanelLayout2Component: React.FC 0) { + for (const joinTableConfig of config.leftPanel.joinTables) { + if (!joinTableConfig.joinTable || !joinTableConfig.mainColumn || !joinTableConfig.joinColumn) { + continue; + } + // 메인 데이터에서 조인할 키 값들 추출 + const joinKeys = [ + ...new Set(data.map((item: Record) => item[joinTableConfig.mainColumn]).filter(Boolean)), + ]; + if (joinKeys.length === 0) continue; + + try { + const joinResponse = await apiClient.post(`/table-management/tables/${joinTableConfig.joinTable}/data`, { + page: 1, + size: 1000, + dataFilter: { + enabled: true, + matchType: "any", + filters: joinKeys.map((key, idx) => ({ + id: `join_key_${idx}`, + columnName: joinTableConfig.joinColumn, + operator: "equals", + value: String(key), + valueType: "static", + })), + }, + autoFilter: { + enabled: true, + filterColumn: "company_code", + filterType: "company", + }, + }); + + if (joinResponse.data.success) { + const joinDataArray = joinResponse.data.data?.data || []; + const joinDataMap = new Map>(); + joinDataArray.forEach((item: Record) => { + const key = item[joinTableConfig.joinColumn]; + if (key) joinDataMap.set(String(key), item); + }); + + if (joinDataMap.size > 0) { + data = data.map((item: Record) => { + const joinKey = item[joinTableConfig.mainColumn]; + const joinData = joinDataMap.get(String(joinKey)); + if (joinData) { + const mergedData = { ...item }; + joinTableConfig.selectColumns.forEach((col) => { + // 테이블.컬럼명 형식으로 저장 + mergedData[`${joinTableConfig.joinTable}.${col}`] = joinData[col]; + // 컬럼명만으로도 저장 (기존 값이 없을 때) + if (!(col in mergedData)) { + mergedData[col] = joinData[col]; + } + }); + return mergedData; + } + return item; + }); + } + console.log(`[SplitPanelLayout2] 좌측 조인 테이블 로드: ${joinTableConfig.joinTable}, ${joinDataArray.length}건`); + } + } catch (error) { + console.error(`[SplitPanelLayout2] 좌측 조인 테이블 로드 실패 (${joinTableConfig.joinTable}):`, error); + } + } + } + + // 그룹핑 처리 + if (config.leftPanel.grouping?.enabled && config.leftPanel.grouping.groupByColumn) { + data = groupData(data, config.leftPanel.grouping, config.leftPanel.displayColumns || []); + } + setLeftData(data); console.log(`[SplitPanelLayout2] 좌측 데이터 로드: ${data.length}건 (company_code 필터 적용)`); } @@ -124,7 +370,7 @@ export const SplitPanelLayout2Component: React.FC { - if (!leftSearchTerm) return leftData; + // 1. 먼저 탭 필터링 적용 + const data = filteredLeftDataByTab; + + // 2. 검색어가 없으면 탭 필터링된 데이터 반환 + if (!leftSearchTerm) return data; // 복수 검색 컬럼 지원 (searchColumns 우선, 없으면 searchColumn 사용) const searchColumns = config.leftPanel?.searchColumns?.map((c) => c.columnName).filter(Boolean) || []; const legacyColumn = config.leftPanel?.searchColumn; const columnsToSearch = searchColumns.length > 0 ? searchColumns : legacyColumn ? [legacyColumn] : []; - if (columnsToSearch.length === 0) return leftData; + if (columnsToSearch.length === 0) return data; const filterRecursive = (items: any[]): any[] => { return items.filter((item) => { @@ -731,27 +981,31 @@ export const SplitPanelLayout2Component: React.FC { - if (!rightSearchTerm) return rightData; + // 1. 먼저 탭 필터링 적용 + const data = filteredRightDataByTab; + + // 2. 검색어가 없으면 탭 필터링된 데이터 반환 + if (!rightSearchTerm) return data; // 복수 검색 컬럼 지원 (searchColumns 우선, 없으면 searchColumn 사용) const searchColumns = config.rightPanel?.searchColumns?.map((c) => c.columnName).filter(Boolean) || []; const legacyColumn = config.rightPanel?.searchColumn; const columnsToSearch = searchColumns.length > 0 ? searchColumns : legacyColumn ? [legacyColumn] : []; - if (columnsToSearch.length === 0) return rightData; + if (columnsToSearch.length === 0) return data; - return rightData.filter((item) => { + return data.filter((item) => { // 여러 컬럼 중 하나라도 매칭되면 포함 return columnsToSearch.some((col) => { const value = String(item[col] || "").toLowerCase(); return value.includes(rightSearchTerm.toLowerCase()); }); }); - }, [rightData, rightSearchTerm, config.rightPanel?.searchColumns, config.rightPanel?.searchColumn]); + }, [filteredRightDataByTab, rightSearchTerm, config.rightPanel?.searchColumns, config.rightPanel?.searchColumn]); // 체크박스 전체 선택/해제 (filteredRightData 정의 이후에 위치해야 함) const handleSelectAll = useCallback( @@ -835,7 +1089,7 @@ export const SplitPanelLayout2Component: React.FC { // col.name이 "테이블명.컬럼명" 형식인 경우 처리 @@ -843,28 +1097,66 @@ export const SplitPanelLayout2Component: React.FC jt.joinTable === effectiveSourceTable); - if (joinTable?.alias) { - const aliasKey = `${joinTable.alias}_${actualColName}`; - if (item[aliasKey] !== undefined) { - return item[aliasKey]; + baseValue = item[tableColumnKey]; + } else { + // 2. 조인 테이블의 alias가 설정된 경우 alias_컬럼명으로 시도 + const joinTable = config.rightPanel?.joinTables?.find((jt) => jt.joinTable === effectiveSourceTable); + if (joinTable?.alias) { + const aliasKey = `${joinTable.alias}_${actualColName}`; + if (item[aliasKey] !== undefined) { + baseValue = item[aliasKey]; + } + } + // 3. 그냥 컬럼명으로 시도 (메인 테이블에 없는 경우 조인 데이터가 직접 들어감) + if (baseValue === undefined && item[actualColName] !== undefined) { + baseValue = item[actualColName]; } } - // 3. 그냥 컬럼명으로 시도 (메인 테이블에 없는 경우 조인 데이터가 직접 들어감) - if (item[actualColName] !== undefined) { - return item[actualColName]; + } else { + // 4. 기본: 컬럼명으로 직접 접근 + baseValue = item[actualColName]; + } + + // 엔티티 참조 설정이 있는 경우 - 선택된 컬럼들의 값을 결합 + if (col.entityReference?.displayColumns && col.entityReference.displayColumns.length > 0) { + // 엔티티 참조 컬럼들의 값을 수집 + // 백엔드에서 entity 조인을 통해 "컬럼명_참조테이블컬럼" 형태로 데이터가 들어옴 + const entityValues: string[] = []; + + for (const displayCol of col.entityReference.displayColumns) { + // 다양한 형식으로 값을 찾아봄 + // 1. 직접 컬럼명 (entity 조인 결과) + if (item[displayCol] !== undefined && item[displayCol] !== null) { + entityValues.push(String(item[displayCol])); + } + // 2. 컬럼명_참조컬럼 형식 + else if (item[`${actualColName}_${displayCol}`] !== undefined) { + entityValues.push(String(item[`${actualColName}_${displayCol}`])); + } + // 3. 참조테이블.컬럼 형식 + else if (col.entityReference.entityId) { + const refTableCol = `${col.entityReference.entityId}.${displayCol}`; + if (item[refTableCol] !== undefined && item[refTableCol] !== null) { + entityValues.push(String(item[refTableCol])); + } + } + } + + // 엔티티 값들이 있으면 결합하여 반환 + if (entityValues.length > 0) { + return entityValues.join(" - "); } } - // 4. 기본: 컬럼명으로 직접 접근 - return item[actualColName]; + + return baseValue; }, [config.rightPanel?.tableName, config.rightPanel?.joinTables], ); @@ -969,15 +1261,39 @@ export const SplitPanelLayout2Component: React.FC - {/* 내용 */} + {/* 내용 */}
{/* 이름 행 (Name Row) */} -
+
{primaryValue || "이름 없음"} - {/* 이름 행의 추가 컬럼들 (배지 스타일) */} + {/* 이름 행의 추가 컬럼들 */} {nameRowColumns.slice(1).map((col, idx) => { const value = item[col.name]; - if (!value) return null; + if (value === null || value === undefined) return null; + + // 배지 타입이고 배열인 경우 + if (col.displayConfig?.displayType === "badge" && Array.isArray(value)) { + return ( +
+ {value.map((v, vIdx) => ( + + {formatValue(v, col.format)} + + ))} +
+ ); + } + + // 배지 타입이지만 단일 값인 경우 + if (col.displayConfig?.displayType === "badge") { + return ( + + {formatValue(value, col.format)} + + ); + } + + // 기본 텍스트 스타일 return ( {formatValue(value, col.format)} @@ -987,16 +1303,40 @@ export const SplitPanelLayout2Component: React.FC {/* 정보 행 (Info Row) */} {infoRowColumns.length > 0 && ( -
+
{infoRowColumns .map((col, idx) => { const value = item[col.name]; - if (!value) return null; + if (value === null || value === undefined) return null; + + // 배지 타입이고 배열인 경우 + if (col.displayConfig?.displayType === "badge" && Array.isArray(value)) { + return ( +
+ {value.map((v, vIdx) => ( + + {formatValue(v, col.format)} + + ))} +
+ ); + } + + // 배지 타입이지만 단일 값인 경우 + if (col.displayConfig?.displayType === "badge") { + return ( + + {formatValue(value, col.format)} + + ); + } + + // 기본 텍스트 return {formatValue(value, col.format)}; }) .filter(Boolean) .reduce((acc: React.ReactNode[], curr, idx) => { - if (idx > 0) + if (idx > 0 && !React.isValidElement(curr)) acc.push( | @@ -1020,6 +1360,95 @@ export const SplitPanelLayout2Component: React.FC { + return config.leftPanel?.primaryKeyColumn || config.leftPanel?.hierarchyConfig?.idColumn || "id"; + }, [config.leftPanel?.primaryKeyColumn, config.leftPanel?.hierarchyConfig?.idColumn]); + + // 왼쪽 패널 테이블 렌더링 + const renderLeftTable = () => { + const displayColumns = config.leftPanel?.displayColumns || []; + const pkColumn = getLeftPrimaryKeyColumn(); + + // 값 렌더링 (배지 지원) + const renderCellValue = (item: any, col: ColumnConfig) => { + const value = item[col.name]; + if (value === null || value === undefined) return "-"; + + // 배지 타입이고 배열인 경우 + if (col.displayConfig?.displayType === "badge" && Array.isArray(value)) { + return ( +
+ {value.map((v, vIdx) => ( + + {formatValue(v, col.format)} + + ))} +
+ ); + } + + // 배지 타입이지만 단일 값인 경우 + if (col.displayConfig?.displayType === "badge") { + return ( + + {formatValue(value, col.format)} + + ); + } + + // 기본 텍스트 + return formatValue(value, col.format); + }; + + return ( +
+ + + + {displayColumns.map((col, idx) => ( + + {col.label || col.name} + + ))} + + + + {filteredLeftData.length === 0 ? ( + + + 데이터가 없습니다 + + + ) : ( + filteredLeftData.map((item, index) => { + const itemId = item[pkColumn]; + const isItemSelected = + selectedLeftItem && + (selectedLeftItem === item || + (item[pkColumn] !== undefined && + selectedLeftItem[pkColumn] !== undefined && + selectedLeftItem[pkColumn] === item[pkColumn])); + + return ( + handleLeftItemSelect(item)} + > + {displayColumns.map((col, colIdx) => ( + {renderCellValue(item, col)} + ))} + + ); + }) + )} + +
+
+ ); + }; + // 우측 패널 카드 렌더링 const renderRightCard = (item: any, index: number) => { const displayColumns = config.rightPanel?.displayColumns || []; @@ -1285,6 +1714,11 @@ export const SplitPanelLayout2Component: React.FC {/* 좌측 패널 미리보기 */} -
-
{config.leftPanel?.title || "좌측 패널"}
-
테이블: {config.leftPanel?.tableName || "미설정"}
-
좌측 목록 영역
+
+ {/* 헤더 */} +
+
+
{config.leftPanel?.title || "좌측 패널"}
+
+ {config.leftPanel?.tableName || "테이블 미설정"} +
+
+ {leftButtons.length > 0 && ( +
+ {leftButtons.slice(0, 2).map((btn) => ( +
+ {btn.label} +
+ ))} + {leftButtons.length > 2 && ( +
+{leftButtons.length - 2}
+ )} +
+ )} +
+ + {/* 검색 표시 */} + {config.leftPanel?.showSearch && ( +
+
+ 검색 +
+
+ )} + + {/* 컬럼 미리보기 */} +
+ {leftDisplayColumns.length > 0 ? ( +
+ {/* 샘플 카드 */} + {[1, 2, 3].map((i) => ( +
+
+ {leftDisplayColumns + .filter((col) => col.displayRow === "name" || !col.displayRow) + .slice(0, 2) + .map((col, idx) => ( +
+ {col.label || col.name} +
+ ))} +
+ {leftDisplayColumns.filter((col) => col.displayRow === "info").length > 0 && ( +
+ {leftDisplayColumns + .filter((col) => col.displayRow === "info") + .slice(0, 3) + .map((col) => ( + {col.label || col.name} + ))} +
+ )} +
+ ))} +
+ ) : ( +
+
컬럼 미설정
+
+ )} +
{/* 우측 패널 미리보기 */} -
-
{config.rightPanel?.title || "우측 패널"}
-
테이블: {config.rightPanel?.tableName || "미설정"}
-
우측 상세 영역
+
+ {/* 헤더 */} +
+
+
{config.rightPanel?.title || "우측 패널"}
+
+ {config.rightPanel?.tableName || "테이블 미설정"} +
+
+ {rightButtons.length > 0 && ( +
+ {rightButtons.slice(0, 2).map((btn) => ( +
+ {btn.label} +
+ ))} + {rightButtons.length > 2 && ( +
+{rightButtons.length - 2}
+ )} +
+ )} +
+ + {/* 검색 표시 */} + {config.rightPanel?.showSearch && ( +
+
+ 검색 +
+
+ )} + + {/* 컬럼 미리보기 */} +
+ {rightDisplayColumns.length > 0 ? ( + config.rightPanel?.displayMode === "table" ? ( + // 테이블 모드 미리보기 +
+
+ {config.rightPanel?.showCheckbox && ( +
+ )} + {rightDisplayColumns.slice(0, 4).map((col) => ( +
+ {col.label || col.name} +
+ ))} +
+ {[1, 2, 3].map((i) => ( +
+ {config.rightPanel?.showCheckbox && ( +
+
+
+ )} + {rightDisplayColumns.slice(0, 4).map((col) => ( +
+ --- +
+ ))} +
+ ))} +
+ ) : ( + // 카드 모드 미리보기 +
+ {[1, 2].map((i) => ( +
+
+ {rightDisplayColumns + .filter((col) => col.displayRow === "name" || !col.displayRow) + .slice(0, 2) + .map((col, idx) => ( +
+ {col.label || col.name} +
+ ))} +
+ {rightDisplayColumns.filter((col) => col.displayRow === "info").length > 0 && ( +
+ {rightDisplayColumns + .filter((col) => col.displayRow === "info") + .slice(0, 3) + .map((col) => ( + {col.label || col.name} + ))} +
+ )} +
+ ))} +
+ ) + ) : ( +
+
컬럼 미설정
+
+ )} +
+ + {/* 연결 설정 표시 */} + {(config.joinConfig?.leftColumn || config.joinConfig?.keys?.length) && ( +
+
+ 연결: {config.joinConfig?.leftColumn || config.joinConfig?.keys?.[0]?.leftColumn} →{" "} + {config.joinConfig?.rightColumn || config.joinConfig?.keys?.[0]?.rightColumn} +
+
+ )}
); @@ -1325,12 +1951,36 @@ export const SplitPanelLayout2Component: React.FC

{config.leftPanel?.title || "목록"}

- {config.leftPanel?.showAddButton && ( + {/* actionButtons 배열이 정의되어 있으면(빈 배열 포함) 해당 배열 사용, undefined면 기존 showAddButton 사용 (하위호환) */} + {config.leftPanel?.actionButtons !== undefined ? ( + // 새로운 actionButtons 배열 기반 렌더링 (빈 배열이면 버튼 없음) + config.leftPanel.actionButtons.length > 0 && ( +
+ {config.leftPanel.actionButtons.map((btn, idx) => ( + + ))} +
+ ) + ) : config.leftPanel?.showAddButton ? ( + // 기존 showAddButton 기반 렌더링 (하위호환 - actionButtons가 undefined일 때만) - )} + ) : null}
{/* 검색 */} @@ -1347,15 +1997,49 @@ export const SplitPanelLayout2Component: React.FC + {/* 좌측 패널 탭 */} + {config.leftPanel?.tabConfig?.enabled && leftTabs.length > 0 && ( +
+ {leftTabs.map((tab) => ( + + ))} +
+ )} + {/* 목록 */} -
+
{leftLoading ? (
로딩 중...
+ ) : (config.leftPanel?.displayMode || "card") === "table" ? ( + // 테이블 모드 + renderLeftTable() ) : filteredLeftData.length === 0 ? (
데이터가 없습니다
) : ( + // 카드 모드 (기본)
{filteredLeftData.map((item, index) => renderLeftItem(item, 0, index))}
)}
@@ -1389,15 +2073,18 @@ export const SplitPanelLayout2Component: React.FC
- {/* 복수 액션 버튼 (actionButtons 설정 시) */} - {selectedLeftItem && renderActionButtons()} - - {/* 기존 단일 추가 버튼 (하위 호환성) */} - {config.rightPanel?.showAddButton && selectedLeftItem && !config.rightPanel?.actionButtons?.length && ( - + {/* actionButtons 배열이 정의되어 있으면(빈 배열 포함) 해당 배열 사용, undefined면 기존 showAddButton 사용 (하위호환) */} + {selectedLeftItem && ( + config.rightPanel?.actionButtons !== undefined ? ( + // 새로운 actionButtons 배열 기반 렌더링 (빈 배열이면 버튼 없음) + config.rightPanel.actionButtons.length > 0 && renderActionButtons() + ) : config.rightPanel?.showAddButton ? ( + // 기존 showAddButton 기반 렌더링 (하위호환 - actionButtons가 undefined일 때만) + + ) : null )}
@@ -1416,6 +2103,36 @@ export const SplitPanelLayout2Component: React.FC + {/* 우측 패널 탭 */} + {config.rightPanel?.tabConfig?.enabled && rightTabs.length > 0 && selectedLeftItem && ( +
+ {rightTabs.map((tab) => ( + + ))} +
+ )} + {/* 내용 */}
{!selectedLeftItem ? ( diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx index 518f85e0..1c7b7c77 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -8,10 +8,21 @@ import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { Check, ChevronsUpDown, Plus, X } from "lucide-react"; +import { Check, ChevronsUpDown, Plus, X, Settings, Columns, MousePointerClick } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; -import type { SplitPanelLayout2Config, ColumnConfig, DataTransferField, JoinTableConfig } from "./types"; +import type { SplitPanelLayout2Config, ColumnConfig, DataTransferField, JoinTableConfig, ColumnDisplayConfig, ActionButtonConfig, SearchColumnConfig, GroupingConfig, TabConfig, ButtonDataTransferConfig } from "./types"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ColumnConfigModal } from "./ColumnConfigModal"; +import { ActionButtonConfigModal } from "./ActionButtonConfigModal"; // lodash set 대체 함수 const setPath = (obj: any, path: string, value: any): any => { @@ -77,6 +88,26 @@ export const SplitPanelLayout2ConfigPanel: React.FC(null); + const [editingColumnConfig, setEditingColumnConfig] = useState({ + displayType: "text", + aggregate: { enabled: false, function: "DISTINCT" }, + }); + + // 새로운 컬럼 설정 모달 상태 + const [leftColumnModalOpen, setLeftColumnModalOpen] = useState(false); + const [rightColumnModalOpen, setRightColumnModalOpen] = useState(false); + + // 액션 버튼 설정 모달 상태 + const [leftActionButtonModalOpen, setLeftActionButtonModalOpen] = useState(false); + const [rightActionButtonModalOpen, setRightActionButtonModalOpen] = useState(false); + + // 데이터 전달 설정 모달 상태 + const [dataTransferModalOpen, setDataTransferModalOpen] = useState(false); + const [editingTransferIndex, setEditingTransferIndex] = useState(null); + // 테이블 목록 로드 const loadTables = useCallback(async () => { setTablesLoading(true); @@ -337,9 +368,9 @@ export const SplitPanelLayout2ConfigPanel: React.FC ( { - onValueChange(selectedValue); + value={`${table.table_name} ${table.table_comment || ""}`} + onSelect={() => { + onValueChange(table.table_name); onOpenChange(false); }} > @@ -485,6 +516,74 @@ export const SplitPanelLayout2ConfigPanel: React.FC void; + placeholder: string; + disabled?: boolean; + }> = ({ columns, value, onValueChange, placeholder, disabled = false }) => { + const [open, setOpen] = useState(false); + + // 현재 선택된 값의 라벨 찾기 + const selectedColumn = columns.find((col) => col.column_name === value); + const displayValue = selectedColumn + ? `${selectedColumn.column_comment || selectedColumn.column_name} (${selectedColumn.column_name})` + : ""; + + return ( + + + + + + + + + 검색 결과가 없습니다 + + {columns.map((col, index) => ( + { + onValueChange(col.column_name); + setOpen(false); + }} + className="text-xs" + > + +
+ {col.column_comment || col.column_name} + {col.column_name} +
+
+ ))} +
+
+
+
+
+ ); + }; + // 조인 테이블 아이템 컴포넌트 const JoinTableItem: React.FC<{ index: number; @@ -743,28 +842,66 @@ export const SplitPanelLayout2ConfigPanel: React.FC { - const currentFields = config.dataTransferFields || []; - updateConfig("dataTransferFields", [...currentFields, { sourceColumn: "", targetColumn: "" }]); - }; + // 컬럼 세부설정 저장 + const handleSaveColumnConfig = () => { + if (editingColumnIndex === null) return; - // 데이터 전달 필드 삭제 - const removeDataTransferField = (index: number) => { - const currentFields = config.dataTransferFields || []; - updateConfig( - "dataTransferFields", - currentFields.filter((_, i) => i !== index), - ); - }; - - // 데이터 전달 필드 업데이트 - const updateDataTransferField = (index: number, field: keyof DataTransferField, value: string) => { - const currentFields = [...(config.dataTransferFields || [])]; - if (currentFields[index]) { - currentFields[index] = { ...currentFields[index], [field]: value }; - updateConfig("dataTransferFields", currentFields); + const currentColumns = [...(config.leftPanel?.displayColumns || [])]; + if (currentColumns[editingColumnIndex]) { + currentColumns[editingColumnIndex] = { + ...currentColumns[editingColumnIndex], + displayConfig: editingColumnConfig, + }; + updateConfig("leftPanel.displayColumns", currentColumns); } + + setColumnConfigModalOpen(false); + setEditingColumnIndex(null); + }; + + // 좌측 컬럼 설정 모달 저장 핸들러 + const handleLeftColumnConfigSave = (columnConfig: { + displayColumns: ColumnConfig[]; + searchColumns: SearchColumnConfig[]; + grouping: GroupingConfig; + showSearch: boolean; + }) => { + // 모든 변경사항을 한 번에 적용 (개별 updateConfig 호출 시 마지막 값만 적용되는 문제 방지) + const newLeftPanel = { + ...config.leftPanel, + displayColumns: columnConfig.displayColumns, + searchColumns: columnConfig.searchColumns, + grouping: columnConfig.grouping, + showSearch: columnConfig.showSearch, + }; + onChange({ ...config, leftPanel: newLeftPanel }); + }; + + // 좌측 액션 버튼 설정 모달 저장 핸들러 + const handleLeftActionButtonSave = (buttons: ActionButtonConfig[]) => { + updateConfig("leftPanel.actionButtons", buttons); + }; + + // 우측 컬럼 설정 모달 저장 핸들러 + const handleRightColumnConfigSave = (columnConfig: { + displayColumns: ColumnConfig[]; + searchColumns: SearchColumnConfig[]; + grouping: GroupingConfig; + showSearch: boolean; + }) => { + // 모든 변경사항을 한 번에 적용 (개별 updateConfig 호출 시 마지막 값만 적용되는 문제 방지) + const newRightPanel = { + ...config.rightPanel, + displayColumns: columnConfig.displayColumns, + searchColumns: columnConfig.searchColumns, + showSearch: columnConfig.showSearch, + }; + onChange({ ...config, rightPanel: newRightPanel }); + }; + + // 우측 액션 버튼 설정 모달 저장 핸들러 + const handleRightActionButtonSave = (buttons: ActionButtonConfig[]) => { + updateConfig("rightPanel.actionButtons", buttons); }; return ( @@ -795,163 +932,200 @@ export const SplitPanelLayout2ConfigPanel: React.FC
- {/* 표시 컬럼 */} + {/* 표시 모드 설정 */}
-
- -
+ + {/* 컬럼 설정 버튼 */} +
+
+
+ +

+ 표시 컬럼 {(config.leftPanel?.displayColumns || []).length}개 + {config.leftPanel?.grouping?.enabled && " | 그룹핑 사용"} + {config.leftPanel?.showSearch && " | 검색 사용"} +

+
+ +
+
+ + {/* 액션 버튼 설정 */} +
+
+
+ +

+ {(config.leftPanel?.actionButtons || []).length > 0 + ? `${(config.leftPanel?.actionButtons || []).length}개 버튼` + : config.leftPanel?.showAddButton + ? "기본 추가 버튼" + : "없음"} +

+
+ +
+
+ + {/* 탭 설정 (좌측) */} +
+
+ + { + if (checked) { + updateConfig("leftPanel.tabConfig", { + enabled: true, + mode: "manual", + showCount: true, + }); + } else { + updateConfig("leftPanel.tabConfig.enabled", false); + } + }} + /> +
+ {config.leftPanel?.tabConfig?.enabled && ( +
+ {/* 그룹핑이 설정되어 있지 않으면 안내 메시지 */} + {!config.leftPanel?.grouping?.enabled ? ( +
+

+ 탭 기능을 사용하려면 컬럼 설정에서 그룹핑을 먼저 활성화해주세요. +

+
+ ) : ( + <> +
+ + +

+ 그룹핑/집계된 컬럼 중에서 선택 +

+
+
+ + updateConfig("leftPanel.tabConfig.showCount", checked)} + /> +
+ + )} +
+ )} +
+ + {/* 추가 조인 테이블 설정 (좌측) */} +
+
+ +
-
- {(config.leftPanel?.displayColumns || []).map((col, index) => ( -
-
- 컬럼 {index + 1} - -
- updateDisplayColumn("left", index, "name", value)} - placeholder="컬럼 선택" +

+ 다른 테이블을 조인하면 표시할 컬럼에서 해당 테이블의 컬럼도 선택할 수 있습니다. +

+ {(config.leftPanel?.joinTables || []).length > 0 && ( +
+ {(config.leftPanel?.joinTables || []).map((joinTable, index) => ( + { + const current = [...(config.leftPanel?.joinTables || [])]; + if (typeof fieldOrPartial === "object") { + current[index] = { ...current[index], ...fieldOrPartial }; + } else { + current[index] = { ...current[index], [fieldOrPartial]: value }; + } + updateConfig("leftPanel.joinTables", current); + }} + onRemove={() => { + const current = config.leftPanel?.joinTables || []; + updateConfig( + "leftPanel.joinTables", + current.filter((_, i) => i !== index), + ); + }} /> -
- - updateDisplayColumn("left", index, "label", e.target.value)} - placeholder="라벨명 (미입력 시 컬럼명 사용)" - className="h-8 text-xs" - /> -
-
- - -
-
- ))} - {(config.leftPanel?.displayColumns || []).length === 0 && ( -
- 표시할 컬럼을 추가하세요 -
- )} -
-
- -
- - updateConfig("leftPanel.showSearch", checked)} - /> -
- - {config.leftPanel?.showSearch && ( -
-
- - -
-
- {(config.leftPanel?.searchColumns || []).map((searchCol, index) => ( -
- { - const current = [...(config.leftPanel?.searchColumns || [])]; - current[index] = { ...current[index], columnName: value }; - updateConfig("leftPanel.searchColumns", current); - }} - placeholder="컬럼 선택" - /> - -
))} - {(config.leftPanel?.searchColumns || []).length === 0 && ( -
- 검색할 컬럼을 추가하세요 -
- )}
-
- )} - -
- - updateConfig("leftPanel.showAddButton", checked)} - /> + )}
- - {config.leftPanel?.showAddButton && ( - <> -
- - updateConfig("leftPanel.addButtonLabel", e.target.value)} - placeholder="추가" - className="h-9 text-sm" - /> -
-
- - updateConfig("leftPanel.addModalScreenId", value)} - placeholder="모달 화면 선택" - open={leftModalOpen} - onOpenChange={setLeftModalOpen} - /> -
- - )}
@@ -981,6 +1155,23 @@ export const SplitPanelLayout2ConfigPanel: React.FC
+ {/* 표시 모드 설정 */} +
+ + +
+ {/* 추가 조인 테이블 설정 */}
@@ -1041,572 +1232,157 @@ export const SplitPanelLayout2ConfigPanel: React.FC
- {/* 표시 컬럼 */} -
-
- - -
-

- 테이블을 선택한 후 해당 테이블의 컬럼을 선택하세요. -

-
- {(config.rightPanel?.displayColumns || []).map((col, index) => { - // 선택 가능한 테이블 목록: 메인 테이블 + 조인 테이블들 - const availableTables = [ - config.rightPanel?.tableName, - ...(config.rightPanel?.joinTables || []).map((jt) => jt.joinTable), - ].filter(Boolean) as string[]; - - // 선택된 테이블의 컬럼만 필터링 - const selectedSourceTable = col.sourceTable || config.rightPanel?.tableName; - const filteredColumns = rightColumns.filter((c) => { - // 조인 테이블 컬럼인지 확인 (column_name이 "테이블명.컬럼명" 형태) - const isJoinColumn = c.column_name.includes("."); - - if (selectedSourceTable === config.rightPanel?.tableName) { - // 메인 테이블 선택 시: 조인 컬럼 아닌 것만 - return !isJoinColumn; - } else { - // 조인 테이블 선택 시: 해당 테이블 컬럼만 (테이블명.컬럼명 형태) - return c.column_name.startsWith(`${selectedSourceTable}.`); - } - }); - - // 테이블 라벨 가져오기 - const getTableLabel = (tableName: string) => { - const table = tables.find((t) => t.table_name === tableName); - return table?.table_comment || tableName; - }; - - return ( -
-
- 컬럼 {index + 1} - -
- - {/* 테이블 선택 */} -
- - -
- - {/* 컬럼 선택 */} -
- - -
- - {/* 표시 라벨 */} -
- - updateDisplayColumn("right", index, "label", e.target.value)} - placeholder="라벨명 (미입력 시 컬럼명 사용)" - className="h-8 text-xs" - /> -
- - {/* 표시 위치 */} -
- - -
-
- ); - })} - {(config.rightPanel?.displayColumns || []).length === 0 && ( -
- 표시할 컬럼을 추가하세요 -
- )} -
-
- -
- - updateConfig("rightPanel.showSearch", checked)} - /> -
- - {config.rightPanel?.showSearch && ( -
-
- - -
-

표시할 컬럼 중 검색에 사용할 컬럼을 선택하세요.

-
- {(config.rightPanel?.searchColumns || []).map((searchCol, index) => { - // 표시할 컬럼 정보를 가져와서 테이블명과 함께 표시 - const displayColumns = config.rightPanel?.displayColumns || []; - - // 유효한 컬럼만 필터링 (name이 있는 것만) - const validDisplayColumns = displayColumns.filter((dc) => dc.name && dc.name.trim() !== ""); - - // 현재 선택된 컬럼의 표시 정보 - const selectedDisplayCol = validDisplayColumns.find((dc) => dc.name === searchCol.columnName); - const selectedColInfo = rightColumns.find((c) => c.column_name === searchCol.columnName); - const selectedLabel = - selectedDisplayCol?.label || - selectedColInfo?.column_comment?.replace(/\s*\([^)]+\)$/, "") || - searchCol.columnName; - const selectedTableName = selectedDisplayCol?.sourceTable || config.rightPanel?.tableName || ""; - const selectedTableLabel = - tables.find((t) => t.table_name === selectedTableName)?.table_comment || selectedTableName; - - return ( -
- - -
- ); - })} - {(config.rightPanel?.displayColumns || []).length === 0 && ( -
- 먼저 표시할 컬럼을 추가하세요 -
- )} - {(config.rightPanel?.displayColumns || []).length > 0 && - (config.rightPanel?.searchColumns || []).length === 0 && ( -
- 검색할 컬럼을 추가하세요 -
- )} -
-
- )} - -
- - updateConfig("rightPanel.showAddButton", checked)} - /> -
- - {config.rightPanel?.showAddButton && ( - <> -
- - updateConfig("rightPanel.addButtonLabel", e.target.value)} - placeholder="추가" - className="h-9 text-sm" - /> -
-
- - updateConfig("rightPanel.addModalScreenId", value)} - placeholder="모달 화면 선택" - open={rightModalOpen} - onOpenChange={setRightModalOpen} - /> -
- - )} - - {/* 표시 모드 설정 */} + {/* 컬럼 설정 버튼 */}
- - -

- 카드형: 카드 형태로 정보 표시, 테이블형: 표 형태로 정보 표시 -

-
- - {/* 카드 모드 전용 옵션 */} - {(config.rightPanel?.displayMode || "card") === "card" && (
- -

라벨: 값 형식으로 표시

+ +

+ 표시 컬럼 {(config.rightPanel?.displayColumns || []).length}개 + {config.rightPanel?.showSearch && " | 검색 사용"} +

- updateConfig("rightPanel.showLabels", checked)} - /> -
- )} - - {/* 체크박스 표시 */} -
-
- -

항목 선택 기능 활성화

-
- updateConfig("rightPanel.showCheckbox", checked)} - /> -
- - {/* 수정/삭제 버튼 */} -
- -
-
- - updateConfig("rightPanel.showEditButton", checked)} - /> -
-
- - updateConfig("rightPanel.showDeleteButton", checked)} - /> -
-
-
- - {/* 수정 모달 화면 (수정 버튼 활성화 시) */} - {config.rightPanel?.showEditButton && ( -
- - updateConfig("rightPanel.editModalScreenId", value)} - placeholder="수정 모달 화면 선택 (미선택 시 추가 모달 사용)" - open={false} - onOpenChange={() => {}} - /> -

미선택 시 추가 모달 화면을 수정용으로 사용

-
- )} - - {/* 기본키 컬럼 */} -
- - updateConfig("rightPanel.primaryKeyColumn", value)} - placeholder="기본키 컬럼 선택 (기본: id)" - /> -

- 수정/삭제 시 사용할 기본키 컬럼 (미선택 시 id 사용) -

-
- - {/* 복수 액션 버튼 설정 */} -
-
-
-

- 복수의 버튼을 추가하면 기존 단일 추가 버튼 대신 사용됩니다 -

-
- {(config.rightPanel?.actionButtons || []).map((btn, index) => ( -
-
- 버튼 {index + 1} - -
-
- - { - const current = [...(config.rightPanel?.actionButtons || [])]; - current[index] = { ...current[index], label: e.target.value }; - updateConfig("rightPanel.actionButtons", current); - }} - placeholder="버튼 라벨" - className="h-8 text-xs" - /> -
-
- - -
-
- - -
-
- - -
- {btn.action === "add" && ( -
- - { - const current = [...(config.rightPanel?.actionButtons || [])]; - current[index] = { ...current[index], modalScreenId: value }; - updateConfig("rightPanel.actionButtons", current); - }} - placeholder="모달 화면 선택" - open={false} - onOpenChange={() => {}} - /> -
- )} -
- ))} - {(config.rightPanel?.actionButtons || []).length === 0 && ( -
- 액션 버튼을 추가하세요 (선택사항) -
- )} +
+ + {/* 액션 버튼 설정 */} +
+
+
+ +

+ {(config.rightPanel?.actionButtons || []).length > 0 + ? `${(config.rightPanel?.actionButtons || []).length}개 버튼` + : config.rightPanel?.showAddButton + ? "기본 추가 버튼" + : "없음"} +

+
+
+ + {/* 탭 설정 (우측) */} +
+
+ + { + if (checked) { + updateConfig("rightPanel.tabConfig", { + enabled: true, + mode: "manual", + showCount: true, + }); + } else { + updateConfig("rightPanel.tabConfig.enabled", false); + } + }} + /> +
+ {config.rightPanel?.tabConfig?.enabled && ( +
+
+ + updateConfig("rightPanel.tabConfig.tabSourceColumn", value)} + placeholder="컬럼 선택..." + disabled={!config.rightPanel?.tableName} + /> +

+ 선택한 컬럼의 고유값으로 탭이 생성됩니다 +

+
+
+ + updateConfig("rightPanel.tabConfig.showCount", checked)} + /> +
+
+ )} +
+ + {/* 기타 옵션들 - 접힌 상태로 표시 */} +
+ 추가 옵션 +
+ {/* 카드 모드 전용 옵션 */} + {(config.rightPanel?.displayMode || "card") === "card" && ( +
+
+ +

라벨: 값 형식으로 표시

+
+ updateConfig("rightPanel.showLabels", checked)} + /> +
+ )} + + {/* 체크박스 표시 */} +
+
+ +

항목 선택 기능 활성화

+
+ updateConfig("rightPanel.showCheckbox", checked)} + /> +
+ + {/* 수정/삭제 버튼 */} +
+ +
+ + updateConfig("rightPanel.showEditButton", checked)} + /> +
+
+ + updateConfig("rightPanel.showDeleteButton", checked)} + /> +
+
+ + {/* 기본키 컬럼 */} +
+ + updateConfig("rightPanel.primaryKeyColumn", value)} + placeholder="기본키 컬럼 선택 (기본: id)" + /> +

+ 수정/삭제 시 사용할 기본키 컬럼 (미선택 시 id 사용) +

+
+
+
@@ -1723,64 +1499,180 @@ export const SplitPanelLayout2ConfigPanel: React.FC {/* 데이터 전달 설정 */} -
-
-

데이터 전달 설정

- -
+
+

데이터 전달 설정

- {/* 설명 */} -
-

우측 패널 추가 버튼 클릭 시 모달로 데이터 전달

-

좌측에서 선택한 항목의 값을 모달 폼에 자동으로 채워줍니다.

-

예: dept_code를 모달의 dept_code 필드에 자동 입력

-
+

+ 버튼 클릭 시 모달에 전달할 데이터를 설정합니다. +

-
- {(config.dataTransferFields || []).map((field, index) => ( -
-
- 필드 {index + 1} - -
-
- - updateDataTransferField(index, "sourceColumn", value)} - placeholder="소스 컬럼" - /> -
-
- - updateDataTransferField(index, "targetColumn", e.target.value)} - placeholder="모달에서 사용할 필드명" - className="h-9 text-sm" - /> -
+ {/* 좌측 패널 버튼 */} + {(config.leftPanel?.actionButtons || []).length > 0 && ( +
+ +
+ {(config.leftPanel?.actionButtons || []).map((btn) => { + // 이 버튼에 대한 데이터 전달 설정 찾기 + const transferConfig = (config.buttonDataTransfers || []).find( + (t) => t.targetPanel === "left" && t.targetButtonId === btn.id + ); + const transferIndex = transferConfig + ? (config.buttonDataTransfers || []).findIndex((t) => t.id === transferConfig.id) + : -1; + + return ( +
+
+
+ {btn.label} + + ({btn.action || "add"}) + +
+ {transferConfig && transferConfig.fields.length > 0 && ( +
+ {transferConfig.fields.length}개 필드: {transferConfig.fields.map((f) => f.sourceColumn).join(", ")} +
+ )} +
+ +
+ ); + })}
- ))} - {(config.dataTransferFields || []).length === 0 && ( +
+ )} + + {/* 우측 패널 버튼 */} + {(config.rightPanel?.actionButtons || []).length > 0 && ( +
+ +
+ {(config.rightPanel?.actionButtons || []).map((btn) => { + // 이 버튼에 대한 데이터 전달 설정 찾기 + const transferConfig = (config.buttonDataTransfers || []).find( + (t) => t.targetPanel === "right" && t.targetButtonId === btn.id + ); + const transferIndex = transferConfig + ? (config.buttonDataTransfers || []).findIndex((t) => t.id === transferConfig.id) + : -1; + + return ( +
+
+
+ {btn.label} + + ({btn.action || "add"}) + +
+ {transferConfig && transferConfig.fields.length > 0 && ( +
+ {transferConfig.fields.length}개 필드: {transferConfig.fields.map((f) => f.sourceColumn).join(", ")} +
+ )} +
+ +
+ ); + })} +
+
+ )} + + {/* 버튼이 없을 때 */} + {(config.leftPanel?.actionButtons || []).length === 0 && + (config.rightPanel?.actionButtons || []).length === 0 && (
- 전달할 필드를 추가하세요 + 설정된 버튼이 없습니다. 먼저 좌측/우측 패널에서 액션 버튼을 추가하세요.
)} -
+ {/* 데이터 전달 상세 설정 모달 */} + { + if (editingTransferIndex !== null) { + const current = [...(config.buttonDataTransfers || [])]; + current[editingTransferIndex] = { ...current[editingTransferIndex], fields }; + updateConfig("buttonDataTransfers", current); + } + setDataTransferModalOpen(false); + setEditingTransferIndex(null); + }} + /> + {/* 레이아웃 설정 */}

레이아웃 설정

@@ -1815,8 +1707,294 @@ export const SplitPanelLayout2ConfigPanel: React.FC
+ + {/* 컬럼 세부설정 모달 */} + + + + 컬럼 세부설정 + + {editingColumnIndex !== null && + config.leftPanel?.displayColumns?.[editingColumnIndex] && + `${config.leftPanel.displayColumns[editingColumnIndex].label || config.leftPanel.displayColumns[editingColumnIndex].name} 컬럼의 표시 방식을 설정합니다.`} + + + +
+ {/* 표시 방식 */} +
+ + +

+ 배지는 여러 값을 태그 형태로 나란히 표시합니다 +

+
+ + {/* 집계 설정 */} +
+
+
+ +

그룹핑 시 값을 집계합니다

+
+ { + setEditingColumnConfig((prev) => ({ + ...prev, + aggregate: { + enabled: checked, + function: prev.aggregate?.function || "DISTINCT", + }, + })); + }} + /> +
+ + {editingColumnConfig.aggregate?.enabled && ( +
+ + +

+ {editingColumnConfig.aggregate?.function === "DISTINCT" + ? "중복을 제거하고 고유한 값들만 배열로 표시합니다" + : "값의 개수를 숫자로 표시합니다"} +

+
+ )} +
+
+ + + + + +
+
+ + {/* 좌측 패널 컬럼 설정 모달 */} + + + {/* 좌측 패널 액션 버튼 설정 모달 */} + + + {/* 우측 패널 컬럼 설정 모달 */} + + + {/* 우측 패널 액션 버튼 설정 모달 */} +
); }; +// 데이터 전달 상세 설정 모달 컴포넌트 +interface DataTransferDetailModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + config: SplitPanelLayout2Config; + editingIndex: number | null; + leftColumns: ColumnInfo[]; + onSave: (fields: DataTransferField[]) => void; +} + +const DataTransferDetailModal: React.FC = ({ + open, + onOpenChange, + config, + editingIndex, + leftColumns, + onSave, +}) => { + const [fields, setFields] = useState([]); + + // 모달 열릴 때 현재 설정 로드 + useEffect(() => { + if (open && editingIndex !== null) { + const transfer = config.buttonDataTransfers?.[editingIndex]; + setFields(transfer?.fields || []); + } + }, [open, editingIndex, config.buttonDataTransfers]); + + const transfer = editingIndex !== null ? config.buttonDataTransfers?.[editingIndex] : null; + + // 소스 컬럼: 대상 패널에 따라 반대 패널의 컬럼 사용 + // left 패널 버튼이면 -> left 패널 데이터를 모달에 전달 (좌측 컬럼이 소스) + // right 패널 버튼이면 -> left 패널 데이터를 모달에 전달 (좌측 컬럼이 소스, 또는 right 컬럼) + const sourceColumns = transfer?.targetPanel === "right" ? leftColumns : leftColumns; + + const addField = () => { + setFields([...fields, { sourceColumn: "", targetColumn: "" }]); + }; + + const removeField = (index: number) => { + setFields(fields.filter((_, i) => i !== index)); + }; + + const updateField = (index: number, key: keyof DataTransferField, value: string) => { + const newFields = [...fields]; + newFields[index] = { ...newFields[index], [key]: value }; + setFields(newFields); + }; + + const targetButton = + transfer?.targetPanel === "left" + ? config.leftPanel?.actionButtons?.find((btn) => btn.id === transfer.targetButtonId) + : config.rightPanel?.actionButtons?.find((btn) => btn.id === transfer?.targetButtonId); + + return ( + + + + 데이터 전달 상세 설정 + + {transfer?.targetPanel === "left" ? "좌측" : "우측"} 패널의 "{targetButton?.label || "버튼"}" 클릭 시 모달에 + 전달할 데이터를 설정합니다. + + + + +
+
+ + +
+ +
+

소스 컬럼: 선택된 항목에서 가져올 컬럼

+

타겟 컬럼: 모달 폼에 전달할 필드명

+
+ +
+ {fields.map((field, index) => ( +
+
+ +
+ -> +
+ updateField(index, "targetColumn", e.target.value)} + placeholder="타겟 컬럼" + className="h-8 text-xs" + /> +
+ +
+ ))} + + {fields.length === 0 && ( +
+ 전달할 필드가 없습니다. 추가 버튼을 클릭하세요. +
+ )} +
+
+
+ + + + + +
+
+ ); +}; + export default SplitPanelLayout2ConfigPanel; diff --git a/frontend/lib/registry/components/split-panel-layout2/components/SearchableColumnSelect.tsx b/frontend/lib/registry/components/split-panel-layout2/components/SearchableColumnSelect.tsx new file mode 100644 index 00000000..24602860 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/components/SearchableColumnSelect.tsx @@ -0,0 +1,163 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Check, ChevronsUpDown, Search } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; + +export interface ColumnInfo { + column_name: string; + data_type: string; + column_comment?: string; +} + +interface SearchableColumnSelectProps { + tableName: string; + value: string; + onValueChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; + excludeColumns?: string[]; // 이미 선택된 컬럼 제외 + className?: string; +} + +export const SearchableColumnSelect: React.FC = ({ + tableName, + value, + onValueChange, + placeholder = "컬럼 선택", + disabled = false, + excludeColumns = [], + className, +}) => { + const [open, setOpen] = useState(false); + const [columns, setColumns] = useState([]); + const [loading, setLoading] = useState(false); + + // 컬럼 목록 로드 + const loadColumns = useCallback(async () => { + if (!tableName) { + setColumns([]); + return; + } + + setLoading(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; + } else if (Array.isArray(response.data)) { + columnList = response.data; + } + + 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 ?? "", + })); + + setColumns(transformedColumns); + } catch (error) { + console.error("컬럼 목록 로드 실패:", error); + setColumns([]); + } finally { + setLoading(false); + } + }, [tableName]); + + useEffect(() => { + loadColumns(); + }, [loadColumns]); + + // 선택된 컬럼 정보 가져오기 + const selectedColumn = columns.find((col) => col.column_name === value); + const displayValue = selectedColumn + ? selectedColumn.column_comment || selectedColumn.column_name + : value || ""; + + // 필터링된 컬럼 목록 (이미 선택된 컬럼 제외) + const filteredColumns = columns.filter( + (col) => !excludeColumns.includes(col.column_name) || col.column_name === value + ); + + return ( + + + + + + + + + + {filteredColumns.length === 0 ? "선택 가능한 컬럼이 없습니다" : "검색 결과가 없습니다"} + + + {filteredColumns.map((col) => ( + { + onValueChange(col.column_name); + setOpen(false); + }} + > + +
+ + {col.column_comment || col.column_name} + + + {col.column_name} ({col.data_type}) + +
+
+ ))} +
+
+
+
+
+ ); +}; + +export default SearchableColumnSelect; + diff --git a/frontend/lib/registry/components/split-panel-layout2/components/SortableColumnItem.tsx b/frontend/lib/registry/components/split-panel-layout2/components/SortableColumnItem.tsx new file mode 100644 index 00000000..8e3cef91 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/components/SortableColumnItem.tsx @@ -0,0 +1,118 @@ +"use client"; + +import React from "react"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { GripVertical, Settings, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { ColumnConfig } from "../types"; + +interface SortableColumnItemProps { + id: string; + column: ColumnConfig; + index: number; + onSettingsClick: () => void; + onRemove: () => void; + showGroupingSettings?: boolean; +} + +export const SortableColumnItem: React.FC = ({ + id, + column, + index, + onSettingsClick, + onRemove, + showGroupingSettings = false, +}) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ {/* 드래그 핸들 */} +
+ +
+ + {/* 컬럼 정보 */} +
+
+ + {column.label || column.name || `컬럼 ${index + 1}`} + + {column.name && column.label && ( + + ({column.name}) + + )} +
+ + {/* 설정 요약 뱃지 */} +
+ {column.displayRow && ( + + {column.displayRow === "name" ? "이름행" : "정보행"} + + )} + {showGroupingSettings && column.displayConfig?.displayType === "badge" && ( + + 배지 + + )} + {showGroupingSettings && column.displayConfig?.aggregate?.enabled && ( + + {column.displayConfig.aggregate.function === "DISTINCT" ? "중복제거" : "개수"} + + )} + {column.sourceTable && ( + + {column.sourceTable} + + )} +
+
+ + {/* 액션 버튼들 */} +
+ + +
+
+ ); +}; + +export default SortableColumnItem; + diff --git a/frontend/lib/registry/components/split-panel-layout2/index.ts b/frontend/lib/registry/components/split-panel-layout2/index.ts index 64a88b11..8810a518 100644 --- a/frontend/lib/registry/components/split-panel-layout2/index.ts +++ b/frontend/lib/registry/components/split-panel-layout2/index.ts @@ -37,5 +37,13 @@ export type { JoinConfig, DataTransferField, ColumnConfig, + ActionButtonConfig, + ValueSourceConfig, + EntityReferenceConfig, + ModalParamMapping, } from "./types"; +// 모달 컴포넌트 내보내기 (별도 사용 필요시) +export { ColumnConfigModal } from "./ColumnConfigModal"; +export { ActionButtonConfigModal } from "./ActionButtonConfigModal"; + diff --git a/frontend/lib/registry/components/split-panel-layout2/types.ts b/frontend/lib/registry/components/split-panel-layout2/types.ts index f5e5290c..00c468e1 100644 --- a/frontend/lib/registry/components/split-panel-layout2/types.ts +++ b/frontend/lib/registry/components/split-panel-layout2/types.ts @@ -3,6 +3,65 @@ * 마스터-디테일 패턴의 좌우 분할 레이아웃 (개선 버전) */ +// ============================================================================= +// 값 소스 및 연동 설정 +// ============================================================================= + +/** + * 값 소스 설정 (화면 내 필드/폼에서 값 가져오기) + */ +export interface ValueSourceConfig { + type: "none" | "field" | "dataForm" | "component"; // 소스 유형 + fieldId?: string; // 필드 컴포넌트 ID + formId?: string; // 데이터폼 ID + formFieldName?: string; // 데이터폼 내 필드명 + componentId?: string; // 다른 컴포넌트 ID + componentColumn?: string; // 컴포넌트에서 참조할 컬럼 +} + +/** + * 엔티티 참조 설정 (엔티티에서 표시할 값 선택) + */ +export interface EntityReferenceConfig { + entityId?: string; // 연결된 엔티티 ID + displayColumns?: string[]; // 표시할 엔티티 컬럼들 (체크박스 선택) + primaryDisplayColumn?: string; // 주 표시 컬럼 +} + +// ============================================================================= +// 컬럼 표시 설정 +// ============================================================================= + +/** + * 컬럼별 표시 설정 (그룹핑 시 사용) + */ +export interface ColumnDisplayConfig { + displayType: "text" | "badge"; // 표시 방식 (텍스트 또는 배지) + aggregate?: { + enabled: boolean; // 집계 사용 여부 + function: "DISTINCT" | "COUNT"; // 집계 방식 (중복제거 또는 개수) + }; +} + +/** + * 그룹핑 설정 (왼쪽 패널용) + */ +export interface GroupingConfig { + enabled: boolean; // 그룹핑 사용 여부 + groupByColumn: string; // 그룹 기준 컬럼 (예: item_id) +} + +/** + * 탭 설정 + */ +export interface TabConfig { + enabled: boolean; // 탭 사용 여부 + mode?: "auto" | "manual"; // 하위 호환성용 (실제로는 manual만 사용) + tabSourceColumn?: string; // 탭 생성 기준 컬럼 + showCount?: boolean; // 탭에 항목 개수 표시 여부 + defaultTab?: string; // 기본 선택 탭 (값 또는 ID) +} + /** * 컬럼 설정 */ @@ -13,6 +72,9 @@ export interface ColumnConfig { displayRow?: "name" | "info"; // 표시 위치 (name: 이름 행, info: 정보 행) width?: number; // 너비 (px) bold?: boolean; // 굵게 표시 + displayConfig?: ColumnDisplayConfig; // 컬럼별 표시 설정 (그룹핑 시) + entityReference?: EntityReferenceConfig; // 엔티티 참조 설정 + valueSource?: ValueSourceConfig; // 값 소스 설정 (화면 내 연동) format?: { type?: "text" | "number" | "currency" | "date"; thousandSeparator?: boolean; @@ -23,27 +85,58 @@ export interface ColumnConfig { }; } +/** + * 모달 파라미터 매핑 설정 + */ +export interface ModalParamMapping { + sourceColumn: string; // 선택된 항목에서 가져올 컬럼 + targetParam: string; // 모달에 전달할 파라미터명 +} + /** * 액션 버튼 설정 */ export interface ActionButtonConfig { id: string; // 고유 ID label: string; // 버튼 라벨 - variant?: "default" | "outline" | "destructive" | "ghost"; // 버튼 스타일 + variant?: "default" | "secondary" | "outline" | "destructive" | "ghost"; // 버튼 스타일 icon?: string; // lucide 아이콘명 (예: "Plus", "Edit", "Trash2") + showCondition?: "always" | "selected" | "notSelected"; // 표시 조건 + action?: "add" | "edit" | "delete" | "bulk-delete" | "api" | "custom"; // 버튼 동작 유형 + + // 모달 관련 modalScreenId?: number; // 연결할 모달 화면 ID - action?: "add" | "edit" | "delete" | "bulk-delete" | "custom"; // 버튼 동작 유형 + modalParams?: ModalParamMapping[]; // 모달에 전달할 파라미터 매핑 + + // API 호출 관련 + apiEndpoint?: string; // API 엔드포인트 + apiMethod?: "GET" | "POST" | "PUT" | "DELETE"; // HTTP 메서드 + confirmMessage?: string; // 확인 메시지 (삭제 등) + + // 커스텀 액션 + customActionId?: string; // 커스텀 액션 식별자 } /** * 데이터 전달 필드 설정 */ export interface DataTransferField { - sourceColumn: string; // 좌측 패널의 컬럼명 + sourceColumn: string; // 소스 패널의 컬럼명 targetColumn: string; // 모달로 전달할 컬럼명 label?: string; // 표시용 라벨 } +/** + * 버튼별 데이터 전달 설정 + * 특정 패널의 특정 버튼에 어떤 데이터를 전달할지 설정 + */ +export interface ButtonDataTransferConfig { + id: string; // 고유 ID + targetPanel: "left" | "right"; // 대상 패널 + targetButtonId: string; // 대상 버튼 ID + fields: DataTransferField[]; // 전달할 필드 목록 +} + /** * 검색 컬럼 설정 */ @@ -62,15 +155,24 @@ export interface LeftPanelConfig { searchColumn?: string; // 검색 대상 컬럼 (단일, 하위 호환성) searchColumns?: SearchColumnConfig[]; // 검색 대상 컬럼들 (복수) showSearch?: boolean; // 검색 표시 여부 - showAddButton?: boolean; // 추가 버튼 표시 - addButtonLabel?: string; // 추가 버튼 라벨 - addModalScreenId?: number; // 추가 모달 화면 ID + showAddButton?: boolean; // 추가 버튼 표시 (하위 호환성) + addButtonLabel?: string; // 추가 버튼 라벨 (하위 호환성) + addModalScreenId?: number; // 추가 모달 화면 ID (하위 호환성) + actionButtons?: ActionButtonConfig[]; // 복수 액션 버튼 배열 + displayMode?: "card" | "table"; // 표시 모드 (card: 카드형, table: 테이블형) + primaryKeyColumn?: string; // 기본키 컬럼명 (선택용, 기본: id) // 계층 구조 설정 hierarchyConfig?: { enabled: boolean; parentColumn: string; // 부모 참조 컬럼 (예: parent_dept_code) idColumn: string; // ID 컬럼 (예: dept_code) }; + // 그룹핑 설정 + grouping?: GroupingConfig; + // 탭 설정 + tabConfig?: TabConfig; + // 추가 조인 테이블 설정 (다른 테이블 참조하여 컬럼 추가 표시) + joinTables?: JoinTableConfig[]; } /** @@ -106,6 +208,8 @@ export interface RightPanelConfig { * - 결과: 부서별 사원 목록에 이메일, 전화번호 등 개인정보 함께 표시 */ joinTables?: JoinTableConfig[]; + // 탭 설정 + tabConfig?: TabConfig; } /** @@ -157,9 +261,12 @@ export interface SplitPanelLayout2Config { // 조인 설정 joinConfig: JoinConfig; - // 데이터 전달 설정 (모달로 전달할 필드) + // 데이터 전달 설정 (하위 호환성 - 기본 설정) dataTransferFields?: DataTransferField[]; + // 버튼별 데이터 전달 설정 (신규) + buttonDataTransfers?: ButtonDataTransferConfig[]; + // 레이아웃 설정 splitRatio?: number; // 좌우 비율 (0-100, 기본 30) resizable?: boolean; // 크기 조절 가능 여부 diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index b2d5a9da..61ad2016 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -865,6 +865,8 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents numberingRules={numberingRules} onLoadTableColumns={loadTableColumns} availableParentFields={availableParentFields} + targetTableName={config.saveConfig?.tableName} + targetTableColumns={config.saveConfig?.tableName ? tableColumns[config.saveConfig.tableName] || [] : []} /> )} diff --git a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx index c8584a5f..f33f5405 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx @@ -10,7 +10,16 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; -import { Plus, Trash2, Settings as SettingsIcon } from "lucide-react"; +import { Plus, Trash2, Settings as SettingsIcon, Check, ChevronsUpDown } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { cn } from "@/lib/utils"; import { FormFieldConfig, @@ -58,6 +67,9 @@ interface FieldDetailSettingsModalProps { onLoadTableColumns: (tableName: string) => void; // 부모 화면에서 전달 가능한 필드 목록 (선택사항) availableParentFields?: AvailableParentField[]; + // 저장 테이블 정보 (타겟 컬럼 선택용) + targetTableName?: string; + targetTableColumns?: { name: string; type: string; label: string }[]; } export function FieldDetailSettingsModal({ @@ -70,13 +82,22 @@ export function FieldDetailSettingsModal({ numberingRules, onLoadTableColumns, availableParentFields = [], + // targetTableName은 타겟 컬럼 선택 시 참고용으로 전달됨 (현재 targetTableColumns만 사용) + targetTableName: _targetTableName, + targetTableColumns = [], }: FieldDetailSettingsModalProps) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + void _targetTableName; // 향후 사용 가능성을 위해 유지 // 로컬 상태로 필드 설정 관리 const [localField, setLocalField] = useState(field); // 전체 카테고리 컬럼 목록 상태 const [categoryColumns, setCategoryColumns] = useState([]); const [loadingCategoryColumns, setLoadingCategoryColumns] = useState(false); + + // Combobox 열림 상태 + const [sourceTableOpen, setSourceTableOpen] = useState(false); + const [targetColumnOpenMap, setTargetColumnOpenMap] = useState>({}); // open이 변경될 때마다 필드 데이터 동기화 useEffect(() => { @@ -649,29 +670,68 @@ export function FieldDetailSettingsModal({
- + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + updateField({ + linkedFieldGroup: { + ...localField.linkedFieldGroup, + sourceTable: t.name, + }, + }); + onLoadTableColumns(t.name); + setSourceTableOpen(false); + }} + className="text-xs" + > + + {t.label || t.name} + ({t.name}) + + ))} + + + + + 값을 가져올 소스 테이블 (예: customer_mng)
@@ -820,14 +880,78 @@ export function FieldDetailSettingsModal({
- - updateLinkedFieldMapping(index, { targetColumn: e.target.value }) - } - placeholder="partner_id" - className="h-6 text-[9px] mt-0.5" - /> + {targetTableColumns.length > 0 ? ( + + setTargetColumnOpenMap((prev) => ({ ...prev, [index]: open })) + } + > + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {targetTableColumns.map((col) => ( + { + updateLinkedFieldMapping(index, { targetColumn: col.name }); + setTargetColumnOpenMap((prev) => ({ ...prev, [index]: false })); + }} + className="text-[9px]" + > + + {col.name} + ({col.label}) + + ))} + + + + + + ) : ( + + updateLinkedFieldMapping(index, { targetColumn: e.target.value }) + } + placeholder="partner_id" + className="h-6 text-[9px] mt-0.5" + /> + )}
))} @@ -966,3 +1090,4 @@ export function FieldDetailSettingsModal({ +