"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, Workflow, } from "lucide-react"; import { getDataFlows, createDataFlow, updateDataFlow, deleteDataFlow, DataFlow, getMultipleScreenLayoutSummary, LayoutItem, getScreenGroup, } 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 { getNodeFlows, createNodeFlow, NodeFlow } from "@/lib/api/nodeFlows"; import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor"; import ScreenDesigner from "@/components/screen/ScreenDesigner"; import { TableSettingModal } from "@/components/screen/TableSettingModal"; 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 ( 결과 없음 {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 [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); // 화면 캔버스 크기 const [showDesignerModal, setShowDesignerModal] = useState(false); // 화면 디자이너 모달 const [showTableSettingModal, setShowTableSettingModal] = useState(false); // 테이블 설정 모달 const [tableSettingTarget, setTableSettingTarget] = useState<{ tableName: string; tableLabel?: string } | null>(null); // 그룹 내 화면 목록 및 현재 선택된 화면 const [groupScreens, setGroupScreens] = useState>([]); const [currentScreenId, setCurrentScreenId] = useState(screenId); const [currentScreenName, setCurrentScreenName] = useState(screenName); const [currentMainTable, setCurrentMainTable] = useState(mainTable); const [currentMainTableLabel, setCurrentMainTableLabel] = useState(mainTableLabel); // 그룹 내 화면 목록 로드 const loadGroupScreens = useCallback(async () => { if (!groupId) return; try { const groupRes = await getScreenGroup(groupId); if (groupRes.success && groupRes.data) { const groupData = groupRes.data as any; const screens = groupData.screens || []; // display_order 순으로 정렬 screens.sort((a: any, b: any) => (a.display_order || 0) - (b.display_order || 0)); setGroupScreens(screens); } } catch (error) { console.error("그룹 화면 목록 로드 실패:", error); } }, [groupId]); // 화면 선택 변경 핸들러 const handleScreenChange = useCallback(async (newScreenId: number) => { const selectedScreen = groupScreens.find(s => s.screen_id === newScreenId); if (!selectedScreen) return; setCurrentScreenId(newScreenId); setCurrentScreenName(selectedScreen.screen_name); setCurrentMainTable(selectedScreen.table_name); // 테이블 라벨은 별도 조회 필요 (일단 테이블명 사용) setCurrentMainTableLabel(selectedScreen.table_name); setIframeKey(prev => prev + 1); // iframe 새로고침 }, [groupScreens]); // 테이블 설정 모달 열기 핸들러 const handleOpenTableSetting = useCallback((tableName: string, tableLabel?: string) => { setTableSettingTarget({ tableName, tableLabel }); setShowTableSettingModal(true); }, []); // 테이블 설정 모달 닫기 핸들러 const handleCloseTableSetting = useCallback(() => { setShowTableSettingModal(false); setTableSettingTarget(null); }, []); // 초기 로드 시 그룹 화면 목록도 로드 useEffect(() => { if (isOpen && groupId) { loadGroupScreens(); } }, [isOpen, groupId, loadGroupScreens]); // props 변경 시 현재 화면 상태 업데이트 useEffect(() => { setCurrentScreenId(screenId); setCurrentScreenName(screenName); setCurrentMainTable(mainTable); setCurrentMainTableLabel(mainTableLabel); }, [screenId, screenName, mainTable, mainTableLabel]); // 데이터 로드 const loadData = useCallback(async () => { if (!currentScreenId) return; setLoading(true); try { // 1. 해당 화면에서 시작하는 데이터 흐름 로드 const flowsResponse = await getDataFlows({ sourceScreenId: currentScreenId }); if (flowsResponse.success && flowsResponse.data) { setDataFlows(flowsResponse.data); } // 2. 화면 레이아웃 요약 정보 로드 (컴포넌트 컬럼 정보 포함) const layoutResponse = await getMultipleScreenLayoutSummary([currentScreenId]); if (layoutResponse.success && layoutResponse.data) { const screenLayout = layoutResponse.data[currentScreenId]; setLayoutItems(screenLayout?.layoutItems || []); // 캔버스 크기 저장 (화면 프리뷰에 사용) setCanvasSize({ width: screenLayout?.canvasWidth || 0, height: screenLayout?.canvasHeight || 0, }); } } catch (error) { console.error("데이터 로드 실패:", error); } finally { setLoading(false); } }, [currentScreenId]); useEffect(() => { if (isOpen && currentScreenId) { loadData(); } }, [isOpen, currentScreenId, loadData]); // 새로고침 (데이터 + iframe) const handleRefresh = useCallback(() => { loadData(); setIframeKey(prev => prev + 1); // iframe 새로고침 }, [loadData]); return ( <> 화면 설정: {groupScreens.length > 1 ? ( ) : ( {currentScreenName} )} 화면의 필드 매핑, 테이블 연결, 데이터 흐름을 확인하고 설정합니다. {/* 2컬럼 레이아웃: 왼쪽 탭(좁게) + 오른쪽 프리뷰(넓게) */}
{/* 왼쪽: 탭 컨텐츠 (40%) */}
개요 제어 관리 데이터 흐름
{/* 탭 1: 화면 개요 */} {/* 탭 2: 제어 관리 */} {/* 탭 3: 데이터 흐름 */}
{/* 오른쪽: 화면 프리뷰 (60%, 항상 표시) */}
{/* ScreenDesigner 전체 화면 모달 */}
{ setShowDesignerModal(false); // 디자이너에서 저장 후 모달 닫으면 데이터 새로고침 await loadData(); // 데이터 로드 완료 후 iframe 갱신 setIframeKey(prev => prev + 1); }} />
{/* TableSettingModal */} {tableSettingTarget && ( { handleRefresh(); }} /> )} ); } // ============================================================ // 통합 테이블 컬럼 아코디언 컴포넌트 // ============================================================ 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; usedFields?: Set; // 화면에서 사용 중인 컬럼 목록 // 필터 테이블 전용 props (optional) mainTable?: string; // 메인 테이블명 (필터 테이블에서 필터 연결 정보 표시용) filterKeyMapping?: FilterKeyMapping; joinColumnRefs?: JoinColumnRef[]; } function TableColumnAccordion({ tableName, tableLabel, tableType, columnMappings = [], onColumnChange, onColumnReorder, onJoinSettingSaved, usedFields = new Set(), 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); // usedFields에서도 확인 (bindField 등에서 가져온 사용 컬럼) const isUsed = !!mapping || usedFields.has(colNameLower) || Array.from(usedFields).some(f => f.toLowerCase() === colNameLower); return { isFilterKey, isJoinKey, joinRef, isUsed, mapping }; }; const ThemeIcon = themeIcon; return (
{/* 헤더 */} {/* 펼쳐진 내용 */} {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 || "-"}
컬럼 변경 없음 {columns.map((c) => ( { if (onColumnChange && selectedMapping) { onColumnChange(editingField, selectedMapping.columnName, c.columnName); } setEditingField(null); }} > {c.displayName || c.columnName} ))}
{/* 필드에서 제거 */} )} {/* 컬럼 기본 정보 (필드가 아닌 경우) */} {!isUsed && (
컬럼명
{selectedColumn?.columnName || editingField}
데이터 타입
{selectedColumn?.dataType || "-"}
)} {/* 조인 설정 */}
조인 {editingJoin && editingJoin.columnName === (selectedColumn?.columnName || editingField) ? "연결 편집" : (hasJoinSetting ? "연결 정보" : "연결 설정")}
{editingJoin && editingJoin.columnName === (selectedColumn?.columnName || editingField) ? (
) : ( )}
{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 */}
대상 테이블 테이블을 찾을 수 없습니다. {allTables.map(t => ( { setEditingJoin({ ...editingJoin, referenceTable: t.tableName, referenceColumn: "", displayColumn: "" }); loadRefTableColumns(t.tableName); setTableSearchOpen(false); }} className="text-xs" > {t.displayName || t.tableName} ))}
{/* 연결 컬럼 선택 - 검색 가능 Combobox */}
연결 컬럼 (PK) 컬럼을 찾을 수 없습니다. {refTableColumns.map(c => ( { setEditingJoin({ ...editingJoin, referenceColumn: c.columnName }); setRefColSearchOpen(false); }} className="text-xs" > {c.displayName || c.columnName} ))}
{/* 표시 컬럼 선택 - 검색 가능 Combobox */}
표시 컬럼 컬럼을 찾을 수 없습니다. {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; // 컬럼 변경 후 새로고침 콜백 onOpenTableSetting?: (tableName: string, tableLabel?: string) => void; // 테이블 설정 모달 열기 } function OverviewTab({ screenId, screenName, mainTable, mainTableLabel, filterTables, fieldMappings, componentCount, dataFlows, layoutItems, loading, onRefresh, onOpenTableSetting, }: 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, }); }); let 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; }); // 폼 화면용 필드 추가/제거 처리 (개별 input 컴포넌트) if (!columnChanged) { // 폼 화면 필드 추가: 새 text-input 컴포넌트 생성 if (isAddingField && newColumn) { console.log("[handleColumnChange] 폼 화면 필드 추가 시도", { newColumn }); // 마지막 컴포넌트 위치 계산 let maxY = 50; // 기본 시작 위치 let lastComponentHeight = 30; currentLayout.components.forEach((comp: any) => { const compY = comp.position?.y || 0; const compHeight = comp.size?.height || 30; if (compY + compHeight > maxY) { maxY = compY + compHeight; lastComponentHeight = compHeight; } }); // 새 컴포넌트 위치: 마지막 컴포넌트 아래 + 간격 const newY = maxY + 10; const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // 새 text-input 컴포넌트 생성 const newComponent = { id: newComponentId, type: "component", label: newColumn, columnName: newColumn, bindField: newColumn, widgetType: "text-input", componentType: "text-input", position: { x: 20, y: newY, z: 1 }, size: { width: 300, height: 30 }, gridColumns: 4, componentConfig: { type: "text-input", webType: "text-input", placeholder: `${newColumn}을(를) 입력하세요`, }, webTypeConfig: {}, style: { labelDisplay: true, labelFontSize: "14px", labelColor: "#212121", width: "300px", height: "30px", }, }; updatedComponents = [...updatedComponents, newComponent]; columnChanged = true; console.log("[handleColumnChange] 폼 화면 필드 추가 완료", { newComponentId, newY }); } // 폼 화면 필드 제거: bindField가 일치하는 컴포넌트 삭제 if (isRemovingField && oldColumn) { console.log("[handleColumnChange] 폼 화면 필드 제거 시도", { oldColumn }); const beforeCount = updatedComponents.length; updatedComponents = updatedComponents.filter((comp: any) => { // bindField, columnName, 또는 properties.columnName으로 매칭 const compBindField = comp.bindField || comp.columnName || comp.properties?.columnName; if (compBindField?.toLowerCase() === oldColumn.toLowerCase()) { console.log("[handleColumnChange] 폼 컴포넌트 제거", { compId: comp.id, compBindField }); return false; // 제거 } return true; // 유지 }); if (beforeCount > updatedComponents.length) { columnChanged = true; console.log("[handleColumnChange] 폼 화면 필드 제거 완료", { beforeCount, afterCount: updatedComponents.length }); } } } 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에서 사용하는 컬럼 수 계산 (usedColumns + bindField) const layoutColumnsSet = new Set(); layoutItems.forEach((item) => { if (item.usedColumns) { item.usedColumns.forEach((col) => layoutColumnsSet.add(col)); } // bindField도 포함 (인풋 필드 등) if (item.bindField) { layoutColumnsSet.add(item.bindField); } }); const layoutColumnCount = layoutColumnsSet.size; return { tableCount: 1 + filterTables.length, // 메인 + 필터 fieldCount: layoutColumnCount > 0 ? layoutColumnCount : fieldMappings.length, joinCount: totalJoins, filterCount: totalFilters, flowCount: dataFlows.length, usedFields: layoutColumnsSet, // 사용 중인 컬럼 Set }; }, [filterTables, fieldMappings, dataFlows, layoutItems]); return (
{/* 기본 정보 카드 */}
{stats.tableCount}
연결된 테이블
{stats.fieldCount}
필드 매핑
{stats.joinCount}
조인 설정
{stats.filterCount}
필터 컬럼
{stats.flowCount}
데이터 흐름
{/* 메인 테이블 (아코디언 형식) */}

메인 테이블

{mainTable && ( )}
{mainTable ? ( a.y - b.y) // 화면 순서대로 정렬 .flatMap((item, idx) => { const cols: string[] = []; // usedColumns에서 가져오기 if (item.usedColumns) { cols.push(...item.usedColumns); } // bindField도 포함 if (item.bindField && !cols.includes(item.bindField)) { cols.push(item.bindField); } return cols.map(col => ({ columnName: col, fieldLabel: col, order: idx * 100 + cols.indexOf(col), })); }) // 중복 제거 (첫 번째 매핑만 유지) .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}개 컬럼
{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); }} > 컬럼을 찾을 수 없습니다. {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 ? "데이터 흐름 수정" : "새 데이터 흐름 추가"}
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="흐름 선택" />