"use client"; import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Textarea } from "@/components/ui/textarea"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { Database, Link2, GitBranch, Columns3, Eye, Save, Plus, Minus, Pencil, Trash2, RefreshCw, Loader2, Check, ChevronsUpDown, ExternalLink, Table2, ArrowRight, Settings2, ChevronDown, ChevronRight, Filter, RotateCcw, X, Zap, MousePointer, Globe, Workflow, Info, } from "lucide-react"; import { getDataFlows, createDataFlow, updateDataFlow, deleteDataFlow, DataFlow, getMultipleScreenLayoutSummary, LayoutItem, } from "@/lib/api/screenGroup"; import { tableManagementApi, ColumnTypeInfo, TableInfo, ColumnSettings } from "@/lib/api/tableManagement"; import { screenApi } from "@/lib/api/screen"; import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"; import { ExternalCallConfigAPI, ExternalCallConfig } from "@/lib/api/externalCallConfig"; import { getFlowDefinitions } from "@/lib/api/flow"; import { FlowDefinition } from "@/types/flow"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; // ============================================================ // 타입 정의 // ============================================================ interface FilterTableInfo { tableName: string; tableLabel?: string; filterColumns?: string[]; // 필터 키 매핑 정보 (메인 테이블.컬럼 → 필터 테이블.컬럼) filterKeyMapping?: { mainTableColumn: string; // 메인 테이블의 컬럼 (leftColumn) mainTableColumnLabel?: string; filterTableColumn: string; // 필터 테이블의 컬럼 (foreignKey) filterTableColumnLabel?: string; }; joinColumnRefs?: Array<{ column: string; refTable: string; refTableLabel?: string; refColumn: string; }>; } interface FieldMappingInfo { targetField: string; sourceField: string; sourceTable?: string; sourceDisplayName?: string; componentType?: string; } interface ScreenSettingModalProps { isOpen: boolean; onClose: () => void; screenId: number; screenName: string; groupId?: number; companyCode?: string; // 프리뷰용 회사 코드 mainTable?: string; mainTableLabel?: string; filterTables?: FilterTableInfo[]; fieldMappings?: FieldMappingInfo[]; componentCount?: number; onSaveSuccess?: () => void; } // 검색 가능한 Select 컴포넌트 interface SearchableSelectProps { value: string; onValueChange: (value: string) => void; options: Array<{ value: string; label: string; description?: string }>; placeholder?: string; disabled?: boolean; className?: string; } function SearchableSelect({ value, onValueChange, options, placeholder = "선택...", disabled = false, className, }: SearchableSelectProps) { const [open, setOpen] = useState(false); const selectedOption = options.find((opt) => opt.value === value); return ( {selectedOption ? ( {selectedOption.label} ) : ( {placeholder} )} 결과 없음 {options.map((option) => ( { onValueChange(option.value); setOpen(false); }} className="text-xs" > {option.label} {option.description && ( {option.description} )} ))} ); } // ============================================================ // 메인 모달 컴포넌트 // ============================================================ export function ScreenSettingModal({ isOpen, onClose, screenId, screenName, groupId, companyCode, mainTable, mainTableLabel, filterTables = [], fieldMappings = [], componentCount = 0, onSaveSuccess, }: ScreenSettingModalProps) { const [activeTab, setActiveTab] = useState("overview"); const [loading, setLoading] = useState(false); const [dataFlows, setDataFlows] = useState([]); const [layoutItems, setLayoutItems] = useState([]); const [iframeKey, setIframeKey] = useState(0); // iframe 새로고침용 키 // 데이터 로드 const loadData = useCallback(async () => { if (!screenId) return; setLoading(true); try { // 1. 해당 화면에서 시작하는 데이터 흐름 로드 const flowsResponse = await getDataFlows({ sourceScreenId: screenId }); if (flowsResponse.success && flowsResponse.data) { setDataFlows(flowsResponse.data); } // 2. 화면 레이아웃 요약 정보 로드 (컴포넌트 컬럼 정보 포함) const layoutResponse = await getMultipleScreenLayoutSummary([screenId]); if (layoutResponse.success && layoutResponse.data) { const screenLayout = layoutResponse.data[screenId]; setLayoutItems(screenLayout?.layoutItems || []); } } catch (error) { console.error("데이터 로드 실패:", error); } finally { setLoading(false); } }, [screenId]); useEffect(() => { if (isOpen && screenId) { loadData(); } }, [isOpen, screenId, loadData]); // 새로고침 (데이터 + iframe) const handleRefresh = useCallback(() => { loadData(); setIframeKey(prev => prev + 1); // iframe 새로고침 }, [loadData]); return ( 화면 설정: {screenName} 화면의 필드 매핑, 테이블 연결, 데이터 흐름을 확인하고 설정합니다. {/* 2컬럼 레이아웃: 왼쪽 탭(좁게) + 오른쪽 프리뷰(넓게) */} {/* 왼쪽: 탭 컨텐츠 (40%) */} 개요 제어 관리 데이터 흐름 {/* 탭 1: 화면 개요 */} {/* 탭 2: 제어 관리 */} {/* 탭 3: 데이터 흐름 */} {/* 오른쪽: 화면 프리뷰 (60%, 항상 표시) */} ); } // ============================================================ // 통합 테이블 컬럼 아코디언 컴포넌트 // ============================================================ interface ColumnMapping { columnName: string; fieldLabel?: string; order: number; // 화면 순서 (y 좌표 기준) } interface JoinColumnRef { column: string; refTable: string; refTableLabel?: string; refColumn: string; displayColumn?: string; } interface FilterKeyMapping { mainTableColumn: string; mainTableColumnLabel?: string; filterTableColumn: string; filterTableColumnLabel?: string; } interface TableColumnAccordionProps { // 공통 props tableName: string; tableLabel?: string; tableType: "main" | "filter"; // 테이블 타입 columnMappings?: ColumnMapping[]; onColumnChange?: (fieldLabel: string, oldColumn: string, newColumn: string) => void; onColumnReorder?: (newOrder: string[]) => void; // 컬럼 순서 변경 콜백 onJoinSettingSaved?: () => void; // 필터 테이블 전용 props (optional) mainTable?: string; // 메인 테이블명 (필터 테이블에서 필터 연결 정보 표시용) filterKeyMapping?: FilterKeyMapping; joinColumnRefs?: JoinColumnRef[]; } function TableColumnAccordion({ tableName, tableLabel, tableType, columnMappings = [], onColumnChange, onColumnReorder, onJoinSettingSaved, mainTable, filterKeyMapping, joinColumnRefs = [], }: TableColumnAccordionProps) { // columnMappings를 Map으로 변환 (컬럼명 → 매핑정보) const columnMappingMap = useMemo(() => { const map = new Map(); columnMappings.forEach(m => map.set(m.columnName.toLowerCase(), m)); return map; }, [columnMappings]); const [isOpen, setIsOpen] = useState(false); const [columns, setColumns] = useState([]); const [loadingColumns, setLoadingColumns] = useState(false); // 편집 중인 필드 const [editingField, setEditingField] = useState(null); // 조인 설정 관련 상태 const [allTables, setAllTables] = useState([]); const [refTableColumns, setRefTableColumns] = useState([]); const [loadingRefColumns, setLoadingRefColumns] = useState(false); const [savingJoinSetting, setSavingJoinSetting] = useState(false); // 조인 설정 편집 상태 const [editingJoin, setEditingJoin] = useState<{ columnName: string; referenceTable: string; referenceColumn: string; displayColumn: string; } | null>(null); // 드래그 앤 드롭 상태 const [draggedIndex, setDraggedIndex] = useState(null); const [localColumnOrder, setLocalColumnOrder] = useState(null); // 드래그 중 로컬 순서 // 스타일 설정 (테이블 타입별) const isMain = tableType === "main"; const themeColor = isMain ? "blue" : "purple"; const themeIcon = isMain ? Table2 : Filter; const themeBadge = isMain ? "메인" : "필터"; // 필터 테이블용 플래그 const hasJoinRefs = joinColumnRefs && joinColumnRefs.length > 0; const hasFilterKey = !!filterKeyMapping; // 정렬된 컬럼 목록 const sortedColumns = useMemo(() => { if (columns.length === 0) return []; if (isMain) { // 메인: 사용 중 → 안 쓰는 컬럼 const used: (ColumnTypeInfo & { mapping: ColumnMapping })[] = []; const unused: ColumnTypeInfo[] = []; columns.forEach(col => { const mapping = columnMappingMap.get(col.columnName.toLowerCase()); if (mapping) { used.push({ ...col, mapping }); } else { unused.push(col); } }); used.sort((a, b) => a.mapping.order - b.mapping.order); return [...used, ...unused]; } else { // 필터: 필터키 → 조인키 → 필드 → 안 쓰는 컬럼 const filterKeys: ColumnTypeInfo[] = []; const joinKeys: ColumnTypeInfo[] = []; const fieldCols: (ColumnTypeInfo & { mapping: ColumnMapping })[] = []; const unused: ColumnTypeInfo[] = []; columns.forEach(col => { const colNameLower = col.columnName.toLowerCase(); const isFilterKey = filterKeyMapping?.filterTableColumn?.toLowerCase() === colNameLower; const isJoinKey = joinColumnRefs?.some(j => j.column.toLowerCase() === colNameLower); const mapping = columnMappingMap.get(colNameLower); if (isFilterKey) { filterKeys.push(col); } else if (isJoinKey) { joinKeys.push(col); } else if (mapping) { fieldCols.push({ ...col, mapping }); } else { unused.push(col); } }); fieldCols.sort((a, b) => a.mapping.order - b.mapping.order); return [...filterKeys, ...joinKeys, ...fieldCols, ...unused]; } }, [columns, columnMappingMap, isMain, filterKeyMapping, joinColumnRefs]); // 아코디언 열릴 때 테이블 컬럼 + 전체 테이블 목록 로드 const handleToggle = async () => { const newIsOpen = !isOpen; setIsOpen(newIsOpen); if (newIsOpen && columns.length === 0 && tableName) { setLoadingColumns(true); try { const result = await tableManagementApi.getColumnList(tableName); if (result.success && result.data && result.data.columns) { setColumns(result.data.columns); } if (allTables.length === 0) { const tablesResult = await tableManagementApi.getTableList(); if (tablesResult.success && tablesResult.data) { setAllTables(tablesResult.data); } } } catch (error) { console.error("테이블 컬럼 로드 실패:", error); } finally { setLoadingColumns(false); } } }; // 참조 테이블 선택 시 해당 테이블의 컬럼 로드 const loadRefTableColumns = useCallback(async (refTableName: string) => { if (!refTableName) { setRefTableColumns([]); return; } setLoadingRefColumns(true); try { const result = await tableManagementApi.getColumnList(refTableName); if (result.success && result.data && result.data.columns) { setRefTableColumns(result.data.columns); } } catch (error) { console.error("참조 테이블 컬럼 로드 실패:", error); } finally { setLoadingRefColumns(false); } }, []); // 조인 설정 저장 const handleSaveJoinSetting = useCallback(async () => { if (!editingJoin || !tableName) return; setSavingJoinSetting(true); try { const settings: ColumnSettings = { columnLabel: columns.find(c => c.columnName === editingJoin.columnName)?.displayName || editingJoin.columnName, webType: "entity", detailSettings: JSON.stringify({}), codeCategory: "", codeValue: "", referenceTable: editingJoin.referenceTable, referenceColumn: editingJoin.referenceColumn, displayColumn: editingJoin.displayColumn, }; const result = await tableManagementApi.updateColumnSettings( tableName, editingJoin.columnName, settings ); if (result.success) { toast.success("조인 설정이 저장되었습니다."); setEditingJoin(null); onJoinSettingSaved?.(); } else { toast.error(result.message || "조인 설정 저장에 실패했습니다."); } } catch (error) { console.error("조인 설정 저장 실패:", error); toast.error("조인 설정 저장에 실패했습니다."); } finally { setSavingJoinSetting(false); } }, [editingJoin, tableName, columns, onJoinSettingSaved]); // 조인 설정 편집 시작 const startEditingJoin = useCallback((columnName: string, currentRefTable?: string, currentRefColumn?: string, currentDisplayColumn?: string) => { setEditingJoin({ columnName, referenceTable: currentRefTable || "", referenceColumn: currentRefColumn || "", displayColumn: currentDisplayColumn || "", }); if (currentRefTable) { loadRefTableColumns(currentRefTable); } }, [loadRefTableColumns]); // 드래그 앤 드롭 핸들러 const handleDragStart = useCallback((e: React.DragEvent, index: number) => { setDraggedIndex(index); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(index)); // 드래그 시작 시 현재 순서를 로컬 상태로 저장 const usedColumns = sortedColumns.filter(col => { const colNameLower = col.columnName.toLowerCase(); return columnMappingMap.has(colNameLower); }); setLocalColumnOrder(usedColumns.map(col => col.columnName)); }, [sortedColumns, columnMappingMap]); const handleDragOver = useCallback((e: React.DragEvent, hoverIndex: number) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; if (draggedIndex === null || draggedIndex === hoverIndex || !localColumnOrder) return; // 사용 중인 컬럼 수 체크 if (hoverIndex >= localColumnOrder.length || draggedIndex >= localColumnOrder.length) return; // 로컬 순서만 변경 (저장하지 않음) const newOrder = [...localColumnOrder]; const draggedItem = newOrder[draggedIndex]; newOrder.splice(draggedIndex, 1); newOrder.splice(hoverIndex, 0, draggedItem); setDraggedIndex(hoverIndex); setLocalColumnOrder(newOrder); }, [draggedIndex, localColumnOrder]); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); // 드롭 시 최종 순서로 저장 if (localColumnOrder && onColumnReorder) { onColumnReorder(localColumnOrder); } setDraggedIndex(null); setLocalColumnOrder(null); }, [localColumnOrder, onColumnReorder]); const handleDragEnd = useCallback(() => { // 드래그 취소 시 (드롭 영역 밖으로 나간 경우) setDraggedIndex(null); setLocalColumnOrder(null); }, []); // 컬럼의 특수 상태 확인 (필터 테이블용) const getColumnState = (colNameLower: string) => { const isFilterKey = filterKeyMapping?.filterTableColumn?.toLowerCase() === colNameLower; const joinRef = joinColumnRefs?.find(j => j.column.toLowerCase() === colNameLower); const isJoinKey = !!joinRef; const mapping = columnMappingMap.get(colNameLower); const isUsed = !!mapping; return { isFilterKey, isJoinKey, joinRef, isUsed, mapping }; }; const ThemeIcon = themeIcon; return ( {/* 헤더 */} {isOpen ? ( ) : ( )} {tableLabel || tableName} {tableLabel && tableName !== tableLabel && ( {tableName} )} {themeBadge} {/* 요약 정보 */} {isMain ? ( columns.length > 0 && `${columns.length}개 컬럼` ) : ( <> {hasFilterKey && `${(filterKeyMapping ? 1 : 0)}개 필터`} {hasJoinRefs && hasFilterKey && " / "} {hasJoinRefs && `${joinColumnRefs!.length}개 조인`} > )} {/* 펼쳐진 내용 */} {isOpen && ( {/* 필터 연결 정보 (필터 테이블만) */} {!isMain && filterKeyMapping && ( 필터 {mainTable}.{filterKeyMapping.mainTableColumnLabel || filterKeyMapping.mainTableColumn} = {tableName}.{filterKeyMapping.filterTableColumnLabel || filterKeyMapping.filterTableColumn} )} {/* 테이블 컬럼 정보 */} 테이블 컬럼 ({loadingColumns ? "로딩중..." : `${columns.length}개`}) {columnMappings.length > 0 && ( 화면에서 사용 ({columnMappings.length}개) )} {loadingColumns ? ( ) : sortedColumns.length > 0 ? ( {/* 왼쪽: 컬럼 목록 */} {(() => { // 드래그 중일 때 로컬 순서 적용 let displayColumns = sortedColumns; if (localColumnOrder && localColumnOrder.length > 0) { // 사용 중인 컬럼들을 localColumnOrder에 따라 재정렬 const usedCols = sortedColumns.filter(col => columnMappingMap.has(col.columnName.toLowerCase())); const unusedCols = sortedColumns.filter(col => !columnMappingMap.has(col.columnName.toLowerCase())); const reorderedUsed = localColumnOrder .map(name => usedCols.find(col => col.columnName.toLowerCase() === name.toLowerCase())) .filter(Boolean) as typeof usedCols; displayColumns = [...reorderedUsed, ...unusedCols]; } return displayColumns.map((col, cIdx) => { const colNameLower = col.columnName.toLowerCase(); const { isFilterKey, isJoinKey, isUsed, mapping } = getColumnState(colNameLower); const isSelected = editingField === (mapping?.fieldLabel || col.columnName); const isDragging = draggedIndex === cIdx; // 드래그 가능 여부 (사용 중인 컬럼만) const canDrag = isUsed && !!onColumnReorder; // 스타일 결정 let baseClass = ""; let leftBorderClass = ""; if (isUsed) { baseClass = isSelected ? "bg-blue-100 border-blue-300" : "bg-blue-50 border-blue-200 hover:bg-blue-100 hover:border-blue-300"; if (isJoinKey) { leftBorderClass = "border-l-4 border-l-orange-500"; } else if (isFilterKey) { leftBorderClass = "border-l-4 border-l-purple-400"; } } else if (isJoinKey) { baseClass = isSelected ? "bg-orange-100 border-orange-400" : "bg-orange-50 border-orange-200 hover:bg-orange-100 hover:border-orange-300"; } else if (isFilterKey) { baseClass = isSelected ? "bg-purple-100 border-purple-400" : "bg-purple-50 border-purple-200 hover:bg-purple-100 hover:border-purple-300"; } else { baseClass = isSelected ? "bg-gray-100 border-gray-400" : "bg-gray-50 border-gray-200 hover:bg-gray-100"; } return ( handleDragStart(e, cIdx) : undefined} onDragOver={canDrag ? (e) => handleDragOver(e, cIdx) : undefined} onDrop={canDrag ? handleDrop : undefined} onDragEnd={canDrag ? handleDragEnd : undefined} onClick={() => { setEditingField(mapping?.fieldLabel || col.columnName); setEditingJoin(null); }} className={`flex items-center justify-between gap-2 text-xs rounded px-2 py-1.5 border transition-all cursor-pointer ${baseClass} ${leftBorderClass} ${isDragging ? "opacity-50 scale-95" : ""} ${canDrag ? "cursor-grab active:cursor-grabbing" : ""}`} > {col.displayName || col.columnName} {isFilterKey && ( 필터 )} {isJoinKey && ( 조인 )} {isUsed && ( 필드 )} {col.dataType?.split("(")[0]} ); }); })()} {/* 오른쪽: 컬럼 설정 패널 */} {editingField ? (() => { const selectedMapping = columnMappings.find(m => m.fieldLabel === editingField); const selectedColumn = selectedMapping ? columns.find(c => c.columnName.toLowerCase() === selectedMapping.columnName?.toLowerCase()) : columns.find(c => (c.displayName || c.columnName) === editingField || c.columnName === editingField); const colNameLower = selectedColumn?.columnName?.toLowerCase() || editingField.toLowerCase(); const { isFilterKey, isJoinKey, joinRef, isUsed } = getColumnState(colNameLower); // 조인 정보 - joinColumnRefs에서 먼저 찾고, 없으면 selectedColumn에서 가져옴 const hasJoinSetting = isJoinKey || !!selectedColumn?.referenceTable; return ( 컬럼 설정 {/* 화면 필드 정보 (필드인 경우만) */} {isUsed && ( <> 화면 필드 {selectedColumn?.displayName || selectedMapping?.columnName || editingField} 현재 컬럼 {selectedMapping?.columnName || "-"} 컬럼 변경 {selectedColumn?.displayName || selectedMapping?.columnName || "컬럼 선택"} 없음 {columns.map((c) => ( { if (onColumnChange && selectedMapping) { onColumnChange(editingField, selectedMapping.columnName, c.columnName); } setEditingField(null); }} > {c.displayName || c.columnName} ))} {/* 필드에서 제거 */} { if (selectedMapping && onColumnChange) { onColumnChange(selectedMapping.fieldLabel!, selectedMapping.columnName, "__REMOVE_FIELD__"); toast.success(`"${selectedColumn?.displayName || selectedMapping.columnName}" 필드가 제거되었습니다.`); setEditingField(null); } }} > 필드에서 제거 > )} {/* 컬럼 기본 정보 (필드가 아닌 경우) */} {!isUsed && ( 컬럼명 {selectedColumn?.columnName || editingField} 데이터 타입 {selectedColumn?.dataType || "-"} { if (selectedColumn?.columnName && onColumnChange) { onColumnChange("__NEW_FIELD__", "", selectedColumn.columnName); toast.success(`"${selectedColumn.displayName || selectedColumn.columnName}" 필드가 추가되었습니다.`); setEditingField(null); } }} > 필드로 추가 )} {/* 조인 설정 */} 조인 {editingJoin && editingJoin.columnName === (selectedColumn?.columnName || editingField) ? "연결 편집" : (hasJoinSetting ? "연결 정보" : "연결 설정")} {editingJoin && editingJoin.columnName === (selectedColumn?.columnName || editingField) ? ( setEditingJoin(null)}> 취소 {savingJoinSetting ? "..." : "저장"} ) : ( startEditingJoin( selectedColumn?.columnName || editingField, isJoinKey && joinRef ? joinRef.refTable : (selectedColumn?.referenceTable || ""), isJoinKey && joinRef ? joinRef.refColumn : (selectedColumn?.referenceColumn || ""), selectedColumn?.displayColumn || "" )} > {hasJoinSetting ? "편집" : "추가"} )} {editingJoin && editingJoin.columnName === (selectedColumn?.columnName || editingField) ? ( ) : hasJoinSetting ? ( 대상 테이블: {isJoinKey && joinRef ? joinRef.refTable : selectedColumn?.referenceTable} 연결 컬럼: {isJoinKey && joinRef ? joinRef.refColumn : selectedColumn?.referenceColumn} ) : ( 조인 설정이 없습니다. )} {/* 필터 정보 (필터 키인 경우) - 필터 테이블에서만 */} {!isMain && isFilterKey && filterKeyMapping && ( 필터 필터링 정보 대상 테이블: {mainTable} 연결 컬럼: {filterKeyMapping.mainTableColumn} )} ); })() : ( 필드를 선택하세요 )} ) : ( 컬럼 정보 없음 )} )} ); } // ============================================================ // 조인 설정 편집 컴포넌트 (검색 가능한 Combobox 사용) // ============================================================ interface JoinSettingEditorProps { editingJoin: { columnName: string; referenceTable: string; referenceColumn: string; displayColumn: string; }; setEditingJoin: React.Dispatch>; allTables: TableInfo[]; refTableColumns: ColumnTypeInfo[]; loadingRefColumns: boolean; savingJoinSetting: boolean; loadRefTableColumns: (tableName: string) => void; handleSaveJoinSetting: () => void; } function JoinSettingEditor({ editingJoin, setEditingJoin, allTables, refTableColumns, loadingRefColumns, savingJoinSetting, loadRefTableColumns, handleSaveJoinSetting, }: JoinSettingEditorProps) { const [tableSearchOpen, setTableSearchOpen] = useState(false); const [refColSearchOpen, setRefColSearchOpen] = useState(false); const [displayColSearchOpen, setDisplayColSearchOpen] = useState(false); const selectedTable = allTables.find(t => t.tableName === editingJoin.referenceTable); const selectedRefCol = refTableColumns.find(c => c.columnName === editingJoin.referenceColumn); const selectedDisplayCol = refTableColumns.find(c => c.columnName === editingJoin.displayColumn); return ( {/* 대상 테이블 선택 - 검색 가능 Combobox */} 대상 테이블 {selectedTable?.displayName || editingJoin.referenceTable || "테이블 선택"} 테이블을 찾을 수 없습니다. {allTables.map(t => ( { setEditingJoin({ ...editingJoin, referenceTable: t.tableName, referenceColumn: "", displayColumn: "" }); loadRefTableColumns(t.tableName); setTableSearchOpen(false); }} className="text-xs" > {t.displayName || t.tableName} ))} {/* 연결 컬럼 선택 - 검색 가능 Combobox */} 연결 컬럼 (PK) {loadingRefColumns ? "로딩중..." : (selectedRefCol?.displayName || editingJoin.referenceColumn || "컬럼 선택")} 컬럼을 찾을 수 없습니다. {refTableColumns.map(c => ( { setEditingJoin({ ...editingJoin, referenceColumn: c.columnName }); setRefColSearchOpen(false); }} className="text-xs" > {c.displayName || c.columnName} ))} {/* 표시 컬럼 선택 - 검색 가능 Combobox */} 표시 컬럼 {selectedDisplayCol?.displayName || editingJoin.displayColumn || "컬럼 선택"} 컬럼을 찾을 수 없습니다. {refTableColumns.map(c => ( { setEditingJoin({ ...editingJoin, displayColumn: c.columnName }); setDisplayColSearchOpen(false); }} className="text-xs" > {c.displayName || c.columnName} ))} ); } // ============================================================ // 탭 1: 화면 개요 // ============================================================ interface OverviewTabProps { screenId: number; screenName: string; mainTable?: string; mainTableLabel?: string; filterTables: FilterTableInfo[]; fieldMappings: FieldMappingInfo[]; componentCount: number; dataFlows: DataFlow[]; layoutItems: LayoutItem[]; // 컴포넌트 컬럼 정보 추가 loading: boolean; onRefresh?: () => void; // 컬럼 변경 후 새로고침 콜백 } function OverviewTab({ screenId, screenName, mainTable, mainTableLabel, filterTables, fieldMappings, componentCount, dataFlows, layoutItems, loading, onRefresh, }: OverviewTabProps) { const [isSavingColumn, setIsSavingColumn] = useState(false); // 컬럼 변경 저장 함수 - 화면 디자이너와 동일한 방식 const handleColumnChange = useCallback(async (fieldLabel: string, oldColumn: string, newColumn: string) => { console.log("[handleColumnChange] 시작", { screenId, fieldLabel, oldColumn, newColumn }); if (!screenId) { toast.error("화면 정보가 없습니다."); return; } // 필드 추가/제거 처리 const isAddingField = fieldLabel === "__NEW_FIELD__"; const isRemovingField = newColumn === "__REMOVE_FIELD__"; setIsSavingColumn(true); try { // 1. 현재 레이아웃 가져오기 console.log("[handleColumnChange] 레이아웃 조회 시작", { screenId }); const currentLayout = await screenApi.getLayout(screenId); console.log("[handleColumnChange] 레이아웃 조회 완료", { hasLayout: !!currentLayout, hasComponents: !!currentLayout?.components, componentCount: currentLayout?.components?.length }); if (!currentLayout?.components) { toast.error("레이아웃 정보를 불러올 수 없습니다."); console.error("[handleColumnChange] 레이아웃 정보 없음", { currentLayout }); return; } // 2. 레이아웃에서 해당 컬럼 변경 let columnChanged = false; // 디버깅: 각 컴포넌트의 구조 확인 console.log("[handleColumnChange] 컴포넌트 구조 분석 시작"); currentLayout.components.forEach((comp: any, i: number) => { console.log(`[handleColumnChange] 컴포넌트 ${i}:`, { id: comp.id, componentType: comp.componentType, hasUsedColumns: !!comp.usedColumns, usedColumns: comp.usedColumns, hasComponentConfig: !!comp.componentConfig, componentConfigKeys: comp.componentConfig ? Object.keys(comp.componentConfig) : [], componentConfigColumns: comp.componentConfig?.columns, componentConfigUsedColumns: comp.componentConfig?.usedColumns, columnName: comp.columnName, bindField: comp.bindField, }); }); const updatedComponents = currentLayout.components.map((comp: any) => { // usedColumns 배열이 있는 컴포넌트에서 oldColumn을 newColumn으로 교체 if (comp.usedColumns && Array.isArray(comp.usedColumns)) { // 필드 추가 if (isAddingField) { console.log("[handleColumnChange] usedColumns에 필드 추가", { compId: comp.id, newColumn }); columnChanged = true; return { ...comp, usedColumns: [...comp.usedColumns, newColumn], }; } const idx = comp.usedColumns.findIndex( (col: string) => col.toLowerCase() === oldColumn.toLowerCase() ); if (idx !== -1) { console.log("[handleColumnChange] usedColumns에서 찾음", { compId: comp.id, idx, isRemovingField }); columnChanged = true; // 필드 제거 if (isRemovingField) { return { ...comp, usedColumns: comp.usedColumns.filter((_: string, i: number) => i !== idx), }; } // 컬럼 변경 return { ...comp, usedColumns: comp.usedColumns.map((col: string, i: number) => i === idx ? newColumn : col ), }; } } // componentConfig 내부의 usedColumns도 확인 if (comp.componentConfig?.usedColumns && Array.isArray(comp.componentConfig.usedColumns)) { // 필드 추가 if (isAddingField && !columnChanged) { console.log("[handleColumnChange] componentConfig.usedColumns에 필드 추가", { compId: comp.id, newColumn }); columnChanged = true; return { ...comp, componentConfig: { ...comp.componentConfig, usedColumns: [...comp.componentConfig.usedColumns, newColumn], }, }; } const idx = comp.componentConfig.usedColumns.findIndex( (col: string) => col.toLowerCase() === oldColumn.toLowerCase() ); if (idx !== -1) { console.log("[handleColumnChange] componentConfig.usedColumns에서 찾음", { compId: comp.id, idx, isRemovingField }); columnChanged = true; // 필드 제거 if (isRemovingField) { return { ...comp, componentConfig: { ...comp.componentConfig, usedColumns: comp.componentConfig.usedColumns.filter((_: string, i: number) => i !== idx), }, }; } // 컬럼 변경 return { ...comp, componentConfig: { ...comp.componentConfig, usedColumns: comp.componentConfig.usedColumns.map((col: string, i: number) => i === idx ? newColumn : col ), }, }; } } // componentConfig.columns 배열도 확인 (컬럼 설정 형태) if (comp.componentConfig?.columns && Array.isArray(comp.componentConfig.columns)) { // 필드 추가 if (isAddingField && !columnChanged) { console.log("[handleColumnChange] componentConfig.columns에 필드 추가", { compId: comp.id, newColumn }); columnChanged = true; return { ...comp, componentConfig: { ...comp.componentConfig, columns: [...comp.componentConfig.columns, { field: newColumn, columnName: newColumn }], }, }; } const columnIdx = comp.componentConfig.columns.findIndex( (col: any) => { const colName = typeof col === 'string' ? col : (col.field || col.columnName || col.name); return colName?.toLowerCase() === oldColumn.toLowerCase(); } ); if (columnIdx !== -1) { console.log("[handleColumnChange] componentConfig.columns에서 찾음", { compId: comp.id, columnIdx, isRemovingField }); columnChanged = true; // 필드 제거 if (isRemovingField) { return { ...comp, componentConfig: { ...comp.componentConfig, columns: comp.componentConfig.columns.filter((_: any, i: number) => i !== columnIdx), }, }; } // 컬럼 변경 const updatedColumns = comp.componentConfig.columns.map((col: any, i: number) => { if (i !== columnIdx) return col; if (typeof col === 'string') return newColumn; return { ...col, field: newColumn, columnName: newColumn }; }); return { ...comp, componentConfig: { ...comp.componentConfig, columns: updatedColumns, }, }; } } // columnName 필드 체크 (위젯 컴포넌트) if (comp.columnName?.toLowerCase() === oldColumn.toLowerCase()) { console.log("[handleColumnChange] columnName에서 찾음", { compId: comp.id }); columnChanged = true; return { ...comp, columnName: newColumn, }; } // bindField 필드 체크 (바인딩 필드) if (comp.bindField?.toLowerCase() === oldColumn.toLowerCase()) { console.log("[handleColumnChange] bindField에서 찾음", { compId: comp.id }); columnChanged = true; return { ...comp, bindField: newColumn, }; } // split-panel-layout의 leftPanel.columns 검사 if (comp.componentConfig?.leftPanel?.columns && Array.isArray(comp.componentConfig.leftPanel.columns)) { const leftColumns = comp.componentConfig.leftPanel.columns; console.log("[handleColumnChange] leftPanel.columns 검사:", { compId: comp.id, leftColumnsCount: leftColumns.length, leftColumnsContent: leftColumns.map((col: any) => typeof col === 'string' ? col : (col.name || col.columnName || col.field)), searchingFor: isAddingField ? newColumn : oldColumn.toLowerCase(), isAddingField, isRemovingField, }); // 필드 추가: 배열에 새 컬럼 추가 if (isAddingField) { console.log("[handleColumnChange] 필드 추가", { compId: comp.id, newColumn }); columnChanged = true; return { ...comp, componentConfig: { ...comp.componentConfig, leftPanel: { ...comp.componentConfig.leftPanel, columns: [...leftColumns, { name: newColumn, columnName: newColumn }], }, }, }; } const columnIdx = leftColumns.findIndex((col: any) => { const colName = typeof col === 'string' ? col : (col.name || col.columnName || col.field); return colName?.toLowerCase() === oldColumn.toLowerCase(); }); if (columnIdx !== -1) { console.log("[handleColumnChange] leftPanel.columns에서 찾음", { compId: comp.id, columnIdx, isRemovingField }); columnChanged = true; // 필드 제거: 배열에서 해당 컬럼 제거 if (isRemovingField) { const filteredColumns = leftColumns.filter((_: any, i: number) => i !== columnIdx); return { ...comp, componentConfig: { ...comp.componentConfig, leftPanel: { ...comp.componentConfig.leftPanel, columns: filteredColumns, }, }, }; } // 컬럼 변경 const updatedLeftColumns = leftColumns.map((col: any, i: number) => { if (i !== columnIdx) return col; if (typeof col === 'string') return newColumn; // 객체인 경우 name/columnName 필드 업데이트 return { ...col, name: newColumn, columnName: newColumn }; }); return { ...comp, componentConfig: { ...comp.componentConfig, leftPanel: { ...comp.componentConfig.leftPanel, columns: updatedLeftColumns, }, }, }; } } // split-panel-layout의 rightPanel.columns 검사 if (comp.componentConfig?.rightPanel?.columns && Array.isArray(comp.componentConfig.rightPanel.columns)) { const rightColumns = comp.componentConfig.rightPanel.columns; // 필드 추가: 배열에 새 컬럼 추가 if (isAddingField && !columnChanged) { console.log("[handleColumnChange] 필드 추가 (rightPanel)", { compId: comp.id, newColumn }); columnChanged = true; return { ...comp, componentConfig: { ...comp.componentConfig, rightPanel: { ...comp.componentConfig.rightPanel, columns: [...rightColumns, { name: newColumn, columnName: newColumn }], }, }, }; } const columnIdx = rightColumns.findIndex((col: any) => { const colName = typeof col === 'string' ? col : (col.name || col.columnName || col.field); return colName?.toLowerCase() === oldColumn.toLowerCase(); }); if (columnIdx !== -1) { console.log("[handleColumnChange] rightPanel.columns에서 찾음", { compId: comp.id, columnIdx, isRemovingField }); columnChanged = true; // 필드 제거 if (isRemovingField) { const filteredColumns = rightColumns.filter((_: any, i: number) => i !== columnIdx); return { ...comp, componentConfig: { ...comp.componentConfig, rightPanel: { ...comp.componentConfig.rightPanel, columns: filteredColumns, }, }, }; } // 컬럼 변경 const updatedRightColumns = rightColumns.map((col: any, i: number) => { if (i !== columnIdx) return col; if (typeof col === 'string') return newColumn; return { ...col, name: newColumn, columnName: newColumn }; }); return { ...comp, componentConfig: { ...comp.componentConfig, rightPanel: { ...comp.componentConfig.rightPanel, columns: updatedRightColumns, }, }, }; } } return comp; }); if (!columnChanged) { toast.warning("변경할 컬럼을 찾을 수 없습니다."); console.warn("[handleColumnChange] 변경할 컬럼 없음", { oldColumn, newColumn }); return; } // 3. 저장 console.log("[handleColumnChange] 저장 시작", { screenId, componentCount: updatedComponents.length }); await screenApi.saveLayout(screenId, { ...currentLayout, components: updatedComponents, }); console.log("[handleColumnChange] 저장 완료"); if (isAddingField) { toast.success(`필드가 추가되었습니다: ${newColumn}`); } else if (isRemovingField) { toast.success(`필드가 제거되었습니다: ${oldColumn}`); } else { toast.success(`컬럼이 변경되었습니다: ${oldColumn} → ${newColumn}`); } // 실시간 반영을 위해 콜백 호출 onRefresh?.(); } catch (error) { console.error("컬럼 변경 저장 실패:", error); toast.error("컬럼 변경 저장에 실패했습니다."); } finally { setIsSavingColumn(false); } }, [screenId, onRefresh]); // 컬럼 순서 변경 저장 함수 const handleColumnReorder = useCallback(async (tableType: "main" | "filter", newOrder: string[]) => { console.log("[handleColumnReorder] 시작", { screenId, tableType, newOrder }); if (!screenId) { console.warn("[handleColumnReorder] screenId 없음"); return; } try { // 1. 현재 레이아웃 가져오기 const currentLayout = await screenApi.getLayout(screenId); if (!currentLayout?.components) { console.error("[handleColumnReorder] 레이아웃 정보 없음"); return; } // 2. 레이아웃에서 해당 컬럼들의 순서 변경 let orderChanged = false; const updatedComponents = currentLayout.components.map((comp: any) => { // split-panel-layout의 leftPanel.columns 순서 변경 if (comp.componentConfig?.leftPanel?.columns && Array.isArray(comp.componentConfig.leftPanel.columns)) { const leftColumns = comp.componentConfig.leftPanel.columns as any[]; // newOrder에 따라 leftColumns 재정렬 const reorderedColumns = newOrder.map(colName => { return leftColumns.find((col: any) => { const name = typeof col === 'string' ? col : (col.name || col.columnName || col.field); return name?.toLowerCase() === colName.toLowerCase(); }); }).filter(Boolean); // 원래 없던 컬럼들 유지 (newOrder에 없는 컬럼들) const remainingColumns = leftColumns.filter((col: any) => { const name = typeof col === 'string' ? col : (col.name || col.columnName || col.field); return !newOrder.some(n => n.toLowerCase() === name?.toLowerCase()); }); if (reorderedColumns.length > 0) { orderChanged = true; console.log("[handleColumnReorder] leftPanel.columns 순서 변경", { compId: comp.id, before: leftColumns.map((c: any) => typeof c === 'string' ? c : (c.name || c.columnName)), after: [...reorderedColumns, ...remainingColumns].map((c: any) => typeof c === 'string' ? c : (c.name || c.columnName)), }); return { ...comp, componentConfig: { ...comp.componentConfig, leftPanel: { ...comp.componentConfig.leftPanel, columns: [...reorderedColumns, ...remainingColumns], }, }, }; } } // rightPanel.columns 순서 변경 if (comp.componentConfig?.rightPanel?.columns && Array.isArray(comp.componentConfig.rightPanel.columns)) { const rightColumns = comp.componentConfig.rightPanel.columns as any[]; const reorderedColumns = newOrder.map(colName => { return rightColumns.find((col: any) => { const name = typeof col === 'string' ? col : (col.name || col.columnName || col.field); return name?.toLowerCase() === colName.toLowerCase(); }); }).filter(Boolean); const remainingColumns = rightColumns.filter((col: any) => { const name = typeof col === 'string' ? col : (col.name || col.columnName || col.field); return !newOrder.some(n => n.toLowerCase() === name?.toLowerCase()); }); if (reorderedColumns.length > 0) { orderChanged = true; console.log("[handleColumnReorder] rightPanel.columns 순서 변경", { compId: comp.id, before: rightColumns.map((c: any) => typeof c === 'string' ? c : (c.name || c.columnName)), after: [...reorderedColumns, ...remainingColumns].map((c: any) => typeof c === 'string' ? c : (c.name || c.columnName)), }); return { ...comp, componentConfig: { ...comp.componentConfig, rightPanel: { ...comp.componentConfig.rightPanel, columns: [...reorderedColumns, ...remainingColumns], }, }, }; } } // componentConfig.usedColumns 순서 변경 if (comp.componentConfig?.usedColumns && Array.isArray(comp.componentConfig.usedColumns)) { const usedColumns = comp.componentConfig.usedColumns as string[]; const reorderedColumns = newOrder.filter(colName => usedColumns.some(c => c.toLowerCase() === colName.toLowerCase()) ); const remainingColumns = usedColumns.filter(c => !newOrder.some(n => n.toLowerCase() === c.toLowerCase()) ); if (reorderedColumns.length > 0) { orderChanged = true; console.log("[handleColumnReorder] usedColumns 순서 변경", { compId: comp.id, before: usedColumns, after: [...reorderedColumns, ...remainingColumns], }); return { ...comp, componentConfig: { ...comp.componentConfig, usedColumns: [...reorderedColumns, ...remainingColumns], }, }; } } // componentConfig.columns 순서 변경 if (comp.componentConfig?.columns && Array.isArray(comp.componentConfig.columns)) { const columns = comp.componentConfig.columns as any[]; const reorderedColumns = newOrder.map(colName => { return columns.find((col: any) => { const name = typeof col === 'string' ? col : (col.field || col.columnName || col.name); return name?.toLowerCase() === colName.toLowerCase(); }); }).filter(Boolean); const remainingColumns = columns.filter((col: any) => { const name = typeof col === 'string' ? col : (col.field || col.columnName || col.name); return !newOrder.some(n => n.toLowerCase() === name?.toLowerCase()); }); if (reorderedColumns.length > 0) { orderChanged = true; console.log("[handleColumnReorder] componentConfig.columns 순서 변경", { compId: comp.id, before: columns.map((c: any) => typeof c === 'string' ? c : (c.field || c.columnName)), after: [...reorderedColumns, ...remainingColumns].map((c: any) => typeof c === 'string' ? c : (c.field || c.columnName)), }); return { ...comp, componentConfig: { ...comp.componentConfig, columns: [...reorderedColumns, ...remainingColumns], }, }; } } return comp; }); if (!orderChanged) { console.log("[handleColumnReorder] 순서 변경 없음"); return; } // 3. 레이아웃 저장 console.log("[handleColumnReorder] 레이아웃 저장"); await screenApi.saveLayout(screenId, { ...currentLayout, components: updatedComponents, }); console.log("[handleColumnReorder] 순서 변경 저장 완료"); // 실시간 반영을 위해 콜백 호출 onRefresh?.(); } catch (error) { console.error("[handleColumnReorder] 순서 변경 저장 실패:", error); toast.error("컬럼 순서 변경 저장에 실패했습니다."); } }, [screenId, onRefresh]); // 통계 계산 (layoutItems의 컬럼 수도 포함) const stats = useMemo(() => { const totalJoins = filterTables.reduce( (sum, ft) => sum + (ft.joinColumnRefs?.length || 0), 0 ); const totalFilters = filterTables.reduce( (sum, ft) => sum + (ft.filterColumns?.length || 0), 0 ); // layoutItems에서 사용하는 컬럼 수 계산 const layoutColumnsSet = new Set(); layoutItems.forEach((item) => { if (item.usedColumns) { item.usedColumns.forEach((col) => layoutColumnsSet.add(col)); } }); const layoutColumnCount = layoutColumnsSet.size; return { tableCount: 1 + filterTables.length, // 메인 + 필터 fieldCount: layoutColumnCount > 0 ? layoutColumnCount : fieldMappings.length, joinCount: totalJoins, filterCount: totalFilters, flowCount: dataFlows.length, }; }, [filterTables, fieldMappings, dataFlows, layoutItems]); return ( {/* 기본 정보 카드 */} {stats.tableCount} 연결된 테이블 {stats.fieldCount} 필드 매핑 {stats.joinCount} 조인 설정 {stats.filterCount} 필터 컬럼 {stats.flowCount} 데이터 흐름 {/* 메인 테이블 (아코디언 형식) */} 메인 테이블 {mainTable ? ( a.y - b.y) // 화면 순서대로 정렬 .flatMap((item, idx) => (item.usedColumns || []).map(col => ({ columnName: col, fieldLabel: col, // 컬럼명 자체를 식별자로 사용 (UI에서 columnLabel 표시) order: idx * 100 + (item.usedColumns?.indexOf(col) || 0), // 순서 유지 })) ) // 중복 제거 (첫 번째 매핑만 유지) .filter((mapping, idx, arr) => arr.findIndex(m => m.columnName.toLowerCase() === mapping.columnName.toLowerCase()) === idx ) } onColumnChange={handleColumnChange} onColumnReorder={(newOrder) => handleColumnReorder("main", newOrder)} onJoinSettingSaved={onRefresh} /> ) : ( 메인 테이블이 설정되지 않았습니다. )} {/* 연결된 필터 테이블 (아코디언 형식) */} 필터 테이블 ({filterTables.length}개) {filterTables.length > 0 ? ( {filterTables.map((ft, idx) => { // 이 필터 테이블에서 사용되는 컬럼 매핑 정보 추출 // 1. layoutItems의 usedColumns에서 추출 const usedColumnMappings: ColumnMapping[] = layoutItems .slice() .sort((a, b) => a.y - b.y) .flatMap((item, itemIdx) => (item.usedColumns || []).map(col => ({ columnName: col, fieldLabel: col, order: itemIdx * 100 + (item.usedColumns?.indexOf(col) || 0), })) ); // 2. 조인 컬럼도 필드로 추가 (화면에서 조인 테이블 데이터를 보여주므로) const joinColumnMappings: ColumnMapping[] = (ft.joinColumnRefs || []).map((ref, refIdx) => ({ columnName: ref.column, fieldLabel: ref.column, order: 1000 + refIdx, // 조인 컬럼은 후순위 })); // 3. 합치고 중복 제거 const filterTableColumnMappings: ColumnMapping[] = [...usedColumnMappings, ...joinColumnMappings] .filter((mapping, i, arr) => arr.findIndex(m => m.columnName.toLowerCase() === mapping.columnName.toLowerCase()) === i ); return ( handleColumnReorder("filter", newOrder)} onJoinSettingSaved={onRefresh} /> ); })} ) : ( 연결된 필터 테이블이 없습니다. )} {/* 데이터 흐름 요약 */} 데이터 흐름 ({dataFlows.length}개) {dataFlows.length > 0 ? ( {dataFlows.slice(0, 3).map((flow) => ( {flow.flow_type} {flow.description || "설명 없음"} 화면 {flow.target_screen_id} ))} {dataFlows.length > 3 && ( +{dataFlows.length - 3}개 더 있음 )} ) : ( 설정된 데이터 흐름이 없습니다. )} ); } // ============================================================ // 탭 2: 필드 매핑 // ============================================================ interface FieldMappingTabProps { screenId: number; mainTable?: string; fieldMappings: FieldMappingInfo[]; layoutItems: LayoutItem[]; loading: boolean; } function FieldMappingTab({ screenId, mainTable, fieldMappings, layoutItems, loading, }: FieldMappingTabProps) { // 편집 모드 상태 const [isEditMode, setIsEditMode] = useState(false); // 테이블 컬럼 목록 (편집용) const [tableColumns, setTableColumns] = useState([]); const [loadingTableColumns, setLoadingTableColumns] = useState(false); // 편집 중인 컬럼 정보 const [editingColumn, setEditingColumn] = useState<{ componentIdx: number; columnIdx: number; currentColumn: string; } | null>(null); const [editPopoverOpen, setEditPopoverOpen] = useState(false); // 테이블 컬럼 로드 const loadTableColumns = useCallback(async () => { if (!mainTable || tableColumns.length > 0) return; setLoadingTableColumns(true); try { const result = await tableManagementApi.getColumnList(mainTable); if (result.success && result.data?.columns) { setTableColumns(result.data.columns); } } catch (error) { console.error("테이블 컬럼 로드 실패:", error); } finally { setLoadingTableColumns(false); } }, [mainTable, tableColumns.length]); // 편집 모드 진입 시 컬럼 로드 useEffect(() => { if (isEditMode) { loadTableColumns(); } }, [isEditMode, loadTableColumns]); // 화면 컴포넌트에서 사용하는 컬럼 정보 추출 const componentColumns = useMemo(() => { const result: Array<{ componentKind: string; componentLabel?: string; columns: string[]; joinColumns: string[]; }> = []; layoutItems.forEach((item) => { if (item.usedColumns && item.usedColumns.length > 0) { result.push({ componentKind: item.componentKind, componentLabel: item.label, columns: item.usedColumns, joinColumns: item.joinColumns || [], }); } }); return result; }, [layoutItems]); // 전체 컬럼 수 계산 const totalColumns = useMemo(() => { const allColumns = new Set(); componentColumns.forEach((comp) => { comp.columns.forEach((col) => allColumns.add(col)); }); return allColumns.size; }, [componentColumns]); // 컬럼명 → 표시명 매핑 (테이블 컬럼에서 추출) const columnDisplayMap = useMemo(() => { const map: Record = {}; tableColumns.forEach((tc) => { map[tc.columnName] = tc.displayName || tc.columnName; }); return map; }, [tableColumns]); // 컴포넌트 타입별 그룹핑 (기존 fieldMappings용) const groupedMappings = useMemo(() => { const grouped: Record = {}; fieldMappings.forEach((mapping) => { const type = mapping.componentType || "기타"; if (!grouped[type]) { grouped[type] = []; } grouped[type].push(mapping); }); return grouped; }, [fieldMappings]); const componentTypes = Object.keys(groupedMappings); if (loading) { return ( ); } return ( {/* 화면 컴포넌트별 컬럼 사용 현황 */} 화면 컴포넌트별 컬럼 사용 현황 {isEditMode ? "컬럼을 클릭하여 매핑을 변경할 수 있습니다." : "각 컴포넌트에서 사용하는 테이블 컬럼을 확인합니다."} 총 {totalColumns}개 컬럼 setIsEditMode(!isEditMode)} className="h-7 text-xs" > {isEditMode ? ( <> 보기 모드 > ) : ( <> 편집 모드 > )} {componentColumns.length === 0 ? ( 화면 컴포넌트에서 사용하는 컬럼 정보가 없습니다. ) : ( {componentColumns.map((comp, idx) => ( {/* 컴포넌트 헤더 */} {comp.componentLabel || comp.componentKind} {comp.componentKind} {comp.columns.length}개 필드 {/* 필드 → 컬럼 매핑 테이블 */} {/* 테이블 헤더 */} 필드명 (화면 표시) 컬럼명 (데이터베이스) {/* 매핑 행들 */} {comp.columns.map((col, cIdx) => { const isJoinColumn = comp.joinColumns.includes(col); const displayName = columnDisplayMap[col] || col; const isEditing = editingColumn?.componentIdx === idx && editingColumn?.columnIdx === cIdx; return ( {/* 필드명 (화면 표시) */} {displayName} {isJoinColumn && ( 조인 )} {/* 화살표 */} {/* 컬럼명 (데이터베이스) */} {isEditMode ? ( { if (open) { setEditingColumn({ componentIdx: idx, columnIdx: cIdx, currentColumn: col }); } else { setEditingColumn(null); } setEditPopoverOpen(open); }} > {col} 컬럼을 찾을 수 없습니다. {loadingTableColumns ? ( ) : ( tableColumns.map((tableCol) => ( { toast.info(`컬럼 변경: ${col} → ${value}`, { description: "저장 기능은 아직 구현 중입니다." }); setEditPopoverOpen(false); setEditingColumn(null); }} className="text-xs" > {tableCol.displayName || tableCol.columnName} {tableCol.displayName && tableCol.displayName !== tableCol.columnName && ( {tableCol.columnName} )} {tableCol.columnName === col && ( )} )) )} ) : ( {col} )} ); })} ))} )} {/* 서브 테이블 연결 관계 (기존 fieldMappings) */} {fieldMappings.length > 0 && ( 서브 테이블 연결 관계 메인 테이블과 서브 테이블 간의 필드 연결 관계입니다. 총 {fieldMappings.length}개 연결 # 메인 테이블 컬럼 서브 테이블 서브 테이블 컬럼 연결 타입 {fieldMappings.map((mapping, idx) => ( {idx + 1} {mainTable}.{mapping.targetField} {mapping.sourceTable || "-"} {mapping.sourceField} {mapping.componentType || "-"} ))} {/* 컴포넌트 타입별 요약 */} {componentTypes.length > 0 && ( 연결 타입별 분류 {componentTypes.map((type) => ( {type} {groupedMappings[type].length} ))} )} )} ); } // ============================================================ // 탭 3: 데이터 흐름 // ============================================================ interface DataFlowTabProps { screenId: number; groupId?: number; dataFlows: DataFlow[]; loading: boolean; onReload: () => void; onSaveSuccess?: () => void; } function DataFlowTab({ screenId, groupId, dataFlows, loading, onReload, onSaveSuccess, }: DataFlowTabProps) { const [isEditing, setIsEditing] = useState(false); const [editItem, setEditItem] = useState(null); const [formData, setFormData] = useState({ target_screen_id: "", action_type: "navigate", data_mapping: "", flow_type: "forward", description: "", is_active: "Y", }); // 폼 초기화 const resetForm = () => { setFormData({ target_screen_id: "", action_type: "navigate", data_mapping: "", flow_type: "forward", description: "", is_active: "Y", }); setEditItem(null); setIsEditing(false); }; // 수정 모드 const handleEdit = (item: DataFlow) => { setEditItem(item); setFormData({ target_screen_id: String(item.target_screen_id), action_type: item.action_type, data_mapping: item.data_mapping || "", flow_type: item.flow_type, description: item.description || "", is_active: item.is_active, }); setIsEditing(true); }; // 저장 const handleSave = async () => { if (!formData.target_screen_id) { toast.error("대상 화면을 선택해주세요."); return; } try { const payload = { source_screen_id: screenId, target_screen_id: parseInt(formData.target_screen_id), action_type: formData.action_type, data_mapping: formData.data_mapping || null, flow_type: formData.flow_type, description: formData.description || null, is_active: formData.is_active, }; let response; if (editItem) { response = await updateDataFlow(editItem.id, payload); } else { response = await createDataFlow(payload); } if (response.success) { toast.success(editItem ? "데이터 흐름이 수정되었습니다." : "데이터 흐름이 추가되었습니다."); resetForm(); onReload(); onSaveSuccess?.(); } else { toast.error(response.message || "저장에 실패했습니다."); } } catch (error) { console.error("저장 오류:", error); toast.error("저장 중 오류가 발생했습니다."); } }; // 삭제 const handleDelete = async (id: number) => { if (!confirm("정말로 삭제하시겠습니까?")) return; try { const response = await deleteDataFlow(id); if (response.success) { toast.success("데이터 흐름이 삭제되었습니다."); onReload(); onSaveSuccess?.(); } else { toast.error(response.message || "삭제에 실패했습니다."); } } catch (error) { console.error("삭제 오류:", error); toast.error("삭제 중 오류가 발생했습니다."); } }; if (loading) { return ( ); } return ( {/* 입력 폼 */} {isEditing ? "데이터 흐름 수정" : "새 데이터 흐름 추가"} 대상 화면 ID * setFormData({ ...formData, target_screen_id: e.target.value }) } placeholder="화면 ID" className="h-8 text-xs" /> 액션 타입 setFormData({ ...formData, action_type: v })} options={[ { value: "navigate", label: "화면 이동" }, { value: "modal", label: "모달 열기" }, { value: "callback", label: "콜백" }, { value: "refresh", label: "새로고침" }, ]} placeholder="액션 선택" /> 흐름 타입 setFormData({ ...formData, flow_type: v })} options={[ { value: "forward", label: "전달 (Forward)" }, { value: "return", label: "반환 (Return)" }, { value: "broadcast", label: "브로드캐스트" }, ]} placeholder="흐름 선택" /> 설명 setFormData({ ...formData, description: e.target.value }) } placeholder="데이터 흐름에 대한 설명" className="h-16 resize-none text-xs" /> {isEditing && ( 취소 )} {isEditing ? "수정" : "추가"} {/* 목록 */} 대상 화면 액션 흐름 타입 설명 작업 {dataFlows.length === 0 ? ( 등록된 데이터 흐름이 없습니다. ) : ( dataFlows.map((flow) => ( 화면 {flow.target_screen_id} {flow.action_type} {flow.flow_type} {flow.description || "-"} handleEdit(flow)} > handleDelete(flow.id)} > )) )} ); } // ============================================================ // 탭: 제어 관리 // ============================================================ interface ButtonControlInfo { id: string; label: string; actionType: string; targetTable?: string; operations?: string[]; confirmMessage?: string; hasDataflowControl?: boolean; dataflowControlMode?: string; linkedExternalCall?: { id: number; name: string; }; linkedFlow?: { id: number; name: string; }; } interface ControlManagementTabProps { screenId: number; layoutItems: LayoutItem[]; loading: boolean; onRefresh: () => void; } function ControlManagementTab({ screenId, layoutItems, loading: parentLoading, onRefresh, }: ControlManagementTabProps) { const [buttonControls, setButtonControls] = useState([]); const [externalCalls, setExternalCalls] = useState([]); const [flows, setFlows] = useState([]); const [loading, setLoading] = useState(false); const [expandedButton, setExpandedButton] = useState(null); const [editingButton, setEditingButton] = useState(null); const [editedValues, setEditedValues] = useState>({}); // 테이블 목록 조회 const [tableList, setTableList] = useState([]); // 데이터 로드 const loadData = useCallback(async () => { setLoading(true); try { // 1. 화면 레이아웃에서 버튼 정보 추출 const layoutResponse = await screenApi.getLayout(screenId); console.log("[제어관리] 레이아웃 응답:", layoutResponse); if (layoutResponse?.components) { const buttons: ButtonControlInfo[] = []; // 컴포넌트에서 버튼 추출 (다양한 필드 확인) const extractButtons = (components: any[], depth = 0) => { for (const comp of components) { // 버튼 컴포넌트 필터링 (다양한 조건 확인) const isButton = comp.webType === "button" || comp.componentType === "button" || comp.type === "button" || comp.componentKind?.includes("button") || comp.widgetType === "button"; if (isButton) { const config = comp.componentConfig || {}; const webTypeConfig = comp.webTypeConfig || {}; const action = config.action || {}; console.log("[제어관리] 버튼 발견:", comp); buttons.push({ id: comp.id || comp.componentId || `btn-${buttons.length}`, label: config.text || comp.label || comp.title || comp.name || "버튼", actionType: typeof action === "string" ? action : (action.type || "custom"), targetTable: config.tableName || webTypeConfig.tableName || comp.tableName, operations: action.operations || [], confirmMessage: action.confirmMessage || config.confirmMessage, hasDataflowControl: webTypeConfig.enableDataflowControl, dataflowControlMode: webTypeConfig.dataflowConfig?.controlMode, linkedExternalCall: undefined, // TODO: 연결 정보 조회 linkedFlow: webTypeConfig.dataflowConfig?.flowConfig ? { id: webTypeConfig.dataflowConfig.flowConfig.flowId, name: webTypeConfig.dataflowConfig.flowConfig.flowName, } : undefined, }); } // 자식 컴포넌트 처리 (여러 필드 확인) if (comp.children && Array.isArray(comp.children)) { extractButtons(comp.children, depth + 1); } // componentConfig 내 중첩된 컴포넌트 확인 if (comp.componentConfig?.children && Array.isArray(comp.componentConfig.children)) { extractButtons(comp.componentConfig.children, depth + 1); } // items 배열 확인 (일부 레이아웃에서 사용) if (comp.items && Array.isArray(comp.items)) { extractButtons(comp.items, depth + 1); } } }; extractButtons(layoutResponse.components); console.log("[제어관리] 추출된 버튼:", buttons); setButtonControls(buttons); } // 2. 외부 호출 목록 조회 const externalResponse = await ExternalCallConfigAPI.getConfigs({ is_active: "Y" }); if (externalResponse.success && externalResponse.data) { setExternalCalls(externalResponse.data); } // 3. 플로우 목록 조회 const flowResponse = await getFlowDefinitions({ isActive: true }); if (flowResponse.success && flowResponse.data) { setFlows(flowResponse.data); } // 4. 테이블 목록 조회 const tableResponse = await tableManagementApi.getTableList(); if (tableResponse.success && tableResponse.data) { setTableList(tableResponse.data); } } catch (error) { console.error("제어 관리 데이터 로드 실패:", error); toast.error("데이터 로드 실패"); } finally { setLoading(false); } }, [screenId]); useEffect(() => { loadData(); }, [loadData]); // 버튼 설정 저장 const handleSaveButton = async (buttonId: string) => { const values = editedValues[buttonId]; if (!values) return; try { // 레이아웃에서 해당 버튼 찾아서 업데이트 const layoutResponse = await screenApi.getLayout(screenId); if (!layoutResponse?.components) { toast.error("레이아웃을 불러올 수 없습니다"); return; } // 버튼 컴포넌트 업데이트 const updateButton = (components: any[]): boolean => { for (const comp of components) { if ((comp.id === buttonId || comp.componentId === buttonId) && (comp.webType === "button" || comp.componentKind?.includes("button"))) { // componentConfig 업데이트 if (!comp.componentConfig) comp.componentConfig = {}; if (!comp.componentConfig.action) comp.componentConfig.action = {}; if (values.targetTable) { comp.componentConfig.tableName = values.targetTable; } if (values.confirmMessage !== undefined) { comp.componentConfig.action.confirmMessage = values.confirmMessage; } if (values.operations) { comp.componentConfig.action.operations = values.operations; } // webTypeConfig 업데이트 (플로우 연동) if (!comp.webTypeConfig) comp.webTypeConfig = {}; if (values.linkedFlowId) { comp.webTypeConfig.enableDataflowControl = true; comp.webTypeConfig.dataflowConfig = { controlMode: "flow", flowConfig: { flowId: values.linkedFlowId, flowName: flows.find(f => f.id === values.linkedFlowId)?.name || "", executionTiming: values.flowTiming || "after", }, }; } else if (values.linkedFlowId === null) { // 플로우 연동 해제 comp.webTypeConfig.enableDataflowControl = false; delete comp.webTypeConfig.dataflowConfig; } return true; } if (comp.children && Array.isArray(comp.children)) { if (updateButton(comp.children)) return true; } } return false; }; if (updateButton(layoutResponse.components)) { // 레이아웃 저장 await screenApi.saveLayout(screenId, layoutResponse); toast.success("버튼 설정이 저장되었습니다"); setEditingButton(null); setEditedValues(prev => { const next = { ...prev }; delete next[buttonId]; return next; }); loadData(); onRefresh(); } else { toast.error("버튼을 찾을 수 없습니다"); } } catch (error) { console.error("버튼 설정 저장 실패:", error); toast.error("저장 실패"); } }; // 액션 타입 라벨 const getActionTypeLabel = (type: string) => { const labels: Record = { save: "저장", delete: "삭제", refresh: "새로고침", reset: "초기화", submit: "제출", cancel: "취소", close: "닫기", navigate: "이동", popup: "팝업", custom: "커스텀", }; return labels[type] || type; }; // 액션 타입 색상 const getActionTypeColor = (type: string) => { switch (type) { case "save": return "bg-green-100 text-green-700"; case "delete": return "bg-red-100 text-red-700"; case "refresh": return "bg-blue-100 text-blue-700"; case "submit": return "bg-purple-100 text-purple-700"; default: return "bg-gray-100 text-gray-700"; } }; if (loading || parentLoading) { return ( ); } return ( {/* 버튼 액션 설정 */} 버튼 액션 설정 {buttonControls.length}개 {buttonControls.length === 0 ? ( 버튼이 없습니다 화면 디자이너에서 버튼을 추가하세요 ) : ( {buttonControls.map((btn) => ( {/* 버튼 헤더 */} setExpandedButton(expandedButton === btn.id ? null : btn.id)} > {expandedButton === btn.id ? ( ) : ( )} [{btn.label}] {getActionTypeLabel(btn.actionType)} {btn.targetTable && ( → {btn.targetTable} )} {btn.hasDataflowControl && ( 제어 연동 )} { e.stopPropagation(); window.open(`/admin/screenMng/screenMngList?screenId=${screenId}`, "_blank"); }} title="상세 설정 (화면 디자이너)" > {/* 버튼 상세 (확장 시) */} {expandedButton === btn.id && ( {/* 대상 테이블 */} 대상 테이블 {editingButton === btn.id ? ( setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], targetTable: val } }))} > {tableList.map((t) => ( {t.displayName || t.tableName} ))} ) : ( {btn.targetTable || 미설정} )} {/* 확인 메시지 */} 확인 메시지 {editingButton === btn.id ? ( setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], confirmMessage: e.target.value } }))} className="h-7 text-xs" placeholder="예: 정말 저장하시겠습니까?" /> ) : ( {btn.confirmMessage || 없음} )} {/* 플로우 연동 */} 플로우 연동 {editingButton === btn.id ? ( setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], linkedFlowId: val === "none" ? null : parseInt(val) } }))} > 연동 안함 {flows.map((f) => ( {f.name} ))} ) : ( {btn.linkedFlow ? ( {btn.linkedFlow.name} ) : ( 없음 )} )} {/* 편집/저장 버튼 */} {editingButton === btn.id ? ( <> { setEditingButton(null); setEditedValues(prev => { const next = { ...prev }; delete next[btn.id]; return next; }); }} > 취소 handleSaveButton(btn.id)} > 저장 > ) : ( setEditingButton(btn.id)} > 편집 )} )} ))} )} {/* 외부 연동 */} 외부 연동 {externalCalls.filter(e => e.is_active === "Y").length}개 활성 {externalCalls.length === 0 ? ( 외부 호출 설정이 없습니다 window.open("/admin/automaticMng/exCallConfList", "_blank")} > 외부 호출 관리로 이동 ) : ( {externalCalls.slice(0, 5).map((call) => ( {call.call_type} {call.config_name} window.open(`/admin/automaticMng/exCallConfList?id=${call.id}`, "_blank")} > ))} {externalCalls.length > 5 && ( window.open("/admin/automaticMng/exCallConfList", "_blank")} > +{externalCalls.length - 5}개 더 보기 )} )} 버튼에 외부 호출을 연결하려면 버튼 편집에서 설정하세요 {/* 플로우 연동 */} 플로우 연동 {flows.length}개 {flows.length === 0 ? ( 플로우가 없습니다 window.open("/admin/automaticMng/flowMgmtList", "_blank")} > 플로우 관리로 이동 ) : ( {flows.slice(0, 5).map((flow) => ( 플로우 {flow.name} {flow.tableName} window.open(`/admin/automaticMng/flowMgmtList?id=${flow.id}`, "_blank")} > ))} {flows.length > 5 && ( window.open("/admin/automaticMng/flowMgmtList", "_blank")} > +{flows.length - 5}개 더 보기 )} )} 버튼에 플로우를 연결하려면 버튼 편집에서 설정하세요 ); } // ============================================================ // 탭 4: 화면 프리뷰 (iframe) // ============================================================ interface PreviewTabProps { screenId: number; screenName: string; companyCode?: string; iframeKey?: number; // iframe 새로고침용 키 } function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0 }: PreviewTabProps) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const containerRef = useRef(null); // 화면 디자인 크기 (모달 프리뷰에 맞춘 크기) const designWidth = 1200; const designHeight = 750; // 컨테이너에 맞는 초기 스케일 계산 const [initialScale, setInitialScale] = useState(0.7); // 컨테이너 크기에 맞춰 초기 스케일 계산 useEffect(() => { const updateInitialScale = () => { if (containerRef.current) { const containerWidth = containerRef.current.offsetWidth; const containerHeight = containerRef.current.offsetHeight; // 여백 5px씩만 적용하여 꽉 차게 const scaleX = (containerWidth - 10) / designWidth; const scaleY = (containerHeight - 10) / designHeight; const newScale = Math.min(scaleX, scaleY); setInitialScale(newScale); } }; // 초기 측정 (약간의 딜레이) const timer = setTimeout(updateInitialScale, 200); // 리사이즈 감지 const resizeObserver = new ResizeObserver(updateInitialScale); if (containerRef.current) { resizeObserver.observe(containerRef.current); } return () => { clearTimeout(timer); resizeObserver.disconnect(); }; }, []); // 화면 URL 생성 (preview=true로 사이드바 없이 화면만 표시, company_code 전달) const previewUrl = useMemo(() => { // 현재 호스트 기반으로 URL 생성 const params = new URLSearchParams({ preview: "true" }); // 프리뷰용 회사 코드 추가 (데이터 조회에 필요) if (companyCode) { params.set("company_code", companyCode); } if (typeof window !== "undefined") { const baseUrl = window.location.origin; return `${baseUrl}/screens/${screenId}?${params.toString()}`; } return `/screens/${screenId}?${params.toString()}`; }, [screenId, companyCode]); const handleIframeLoad = () => { setLoading(false); }; const handleIframeError = () => { setLoading(false); setError("화면을 불러오는데 실패했습니다."); }; const openInNewTab = () => { window.open(previewUrl, "_blank"); }; return ( {/* 상단 툴바 (최소화) */} {screenName} (휠: 확대/축소, 드래그: 이동) { setLoading(true); const iframe = document.getElementById("screen-preview-iframe") as HTMLIFrameElement; if (iframe) { iframe.src = iframe.src; } }} className="h-5 w-5 p-0" title="새로고침" > {/* iframe 영역 - Ctrl+휠로 확대/축소, 내부 버튼/목록 클릭 가능 */} {loading && ( 화면 로딩 중... )} {error ? ( ⚠️ {error} { setError(null); setLoading(true); }} > 다시 시도 ) : ( {({ state }) => ( {/* 클릭/드래그 분리 오버레이 */} { const overlay = e.currentTarget; const iframe = overlay.previousElementSibling as HTMLIFrameElement; const startX = e.clientX; const startY = e.clientY; let moved = false; const handleMouseMove = (moveEvent: MouseEvent) => { const dx = Math.abs(moveEvent.clientX - startX); const dy = Math.abs(moveEvent.clientY - startY); if (dx > 5 || dy > 5) { moved = true; overlay.style.cursor = "grabbing"; } }; const handleMouseUp = (upEvent: MouseEvent) => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); overlay.style.cursor = "grab"; // 이동 없이 클릭만 했으면 iframe에 클릭 전달 if (!moved) { const rect = iframe.getBoundingClientRect(); // 실제 표시된 크기와 원본 크기의 비율로 좌표 변환 const scaleX = designWidth / rect.width; const scaleY = designHeight / rect.height; const x = (upEvent.clientX - rect.left) * scaleX; const y = (upEvent.clientY - rect.top) * scaleY; // iframe 내부로 클릭 이벤트 전달 try { const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document; if (iframeDoc) { let elem = iframeDoc.elementFromPoint(x, y) as HTMLElement | null; if (elem) { // SVG 내부 요소(path, line 등)면 가장 가까운 버튼/앵커 찾기 const clickable = elem.closest("button, a, [role='button'], [onclick]") as HTMLElement | null; const target = clickable || elem; // 인풋 요소면 포커스 먼저 if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") { target.focus(); } // 전체 마우스 이벤트 시퀀스 발생 target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: iframe.contentWindow })); target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: iframe.contentWindow })); target.click(); } } } catch { // cross-origin 제한시 무시 } } }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }} /> )} )} ); } export default ScreenSettingModal;
{isEditMode ? "컬럼을 클릭하여 매핑을 변경할 수 있습니다." : "각 컴포넌트에서 사용하는 테이블 컬럼을 확인합니다."}
화면 컴포넌트에서 사용하는 컬럼 정보가 없습니다.
{col}
메인 테이블과 서브 테이블 간의 필드 연결 관계입니다.
버튼이 없습니다
화면 디자이너에서 버튼을 추가하세요
외부 호출 설정이 없습니다
플로우가 없습니다
화면 로딩 중...
{error}