ERP-node/frontend/components/screen/ScreenSettingModal.tsx

3634 lines
144 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect, useCallback, 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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("h-8 w-full justify-between text-xs", className)}
>
{selectedOption ? (
<span className="truncate">{selectedOption.label}</span>
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs">
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => {
onValueChange(option.value);
setOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{option.label}</span>
{option.description && (
<span className="text-muted-foreground text-[10px]">
{option.description}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// ============================================================
// 메인 모달 컴포넌트
// ============================================================
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<DataFlow[]>([]);
const [layoutItems, setLayoutItems] = useState<LayoutItem[]>([]);
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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="flex h-[90vh] max-h-[950px] w-[98vw] max-w-[1600px] flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2 text-lg">
<Settings2 className="h-5 w-5 text-blue-500" />
: {screenName}
</DialogTitle>
<DialogDescription className="text-sm">
, , .
</DialogDescription>
</DialogHeader>
{/* 2컬럼 레이아웃: 왼쪽 탭(좁게) + 오른쪽 프리뷰(넓게) */}
<div className="flex min-h-0 flex-1 gap-3">
{/* 왼쪽: 탭 컨텐츠 (40%) */}
<div className="flex min-h-0 w-[40%] flex-col rounded-lg border bg-white">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex flex-shrink-0 items-center justify-between border-b p-2">
<TabsList className="h-8">
<TabsTrigger value="overview" className="gap-1 text-xs px-2">
<Database className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="control-management" className="gap-1 text-xs px-2">
<Zap className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="data-flow" className="gap-1 text-xs px-2">
<GitBranch className="h-3 w-3" />
</TabsTrigger>
</TabsList>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
className="h-7 w-7 p-0"
>
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
</Button>
</div>
{/* 탭 1: 화면 개요 */}
<TabsContent value="overview" className="mt-0 min-h-0 flex-1 overflow-auto p-3">
<OverviewTab
screenId={screenId}
screenName={screenName}
mainTable={mainTable}
mainTableLabel={mainTableLabel}
filterTables={filterTables}
fieldMappings={fieldMappings}
componentCount={componentCount}
dataFlows={dataFlows}
layoutItems={layoutItems}
loading={loading}
onRefresh={handleRefresh}
/>
</TabsContent>
{/* 탭 2: 제어 관리 */}
<TabsContent value="control-management" className="mt-0 min-h-0 flex-1 overflow-auto p-3">
<ControlManagementTab
screenId={screenId}
layoutItems={layoutItems}
loading={loading}
onRefresh={handleRefresh}
/>
</TabsContent>
{/* 탭 3: 데이터 흐름 */}
<TabsContent value="data-flow" className="mt-0 min-h-0 flex-1 overflow-auto p-3">
<DataFlowTab
screenId={screenId}
groupId={groupId}
dataFlows={dataFlows}
loading={loading}
onReload={loadData}
onSaveSuccess={onSaveSuccess}
/>
</TabsContent>
</Tabs>
</div>
{/* 오른쪽: 화면 프리뷰 (60%, 항상 표시) */}
<div className="flex min-h-0 w-[60%] flex-col overflow-hidden rounded-lg border bg-white">
<PreviewTab screenId={screenId} screenName={screenName} companyCode={companyCode} iframeKey={iframeKey} />
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ============================================================
// 통합 테이블 컬럼 아코디언 컴포넌트
// ============================================================
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<string, ColumnMapping>();
columnMappings.forEach(m => map.set(m.columnName.toLowerCase(), m));
return map;
}, [columnMappings]);
const [isOpen, setIsOpen] = useState(false);
const [columns, setColumns] = useState<ColumnTypeInfo[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
// 편집 중인 필드
const [editingField, setEditingField] = useState<string | null>(null);
// 조인 설정 관련 상태
const [allTables, setAllTables] = useState<TableInfo[]>([]);
const [refTableColumns, setRefTableColumns] = useState<ColumnTypeInfo[]>([]);
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<number | null>(null);
const [localColumnOrder, setLocalColumnOrder] = useState<string[] | null>(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 (
<div className={`rounded-lg border bg-${themeColor}-50/30 overflow-hidden`}>
{/* 헤더 */}
<button
type="button"
onClick={handleToggle}
className={`w-full flex items-center gap-3 p-3 hover:bg-${themeColor}-50/50 transition-colors text-left`}
>
{isOpen ? (
<ChevronDown className={`h-4 w-4 text-${themeColor}-500 flex-shrink-0`} />
) : (
<ChevronRight className={`h-4 w-4 text-${themeColor}-500 flex-shrink-0`} />
)}
<ThemeIcon className={`h-4 w-4 text-${themeColor}-500 flex-shrink-0`} />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{tableLabel || tableName}</div>
{tableLabel && tableName !== tableLabel && (
<div className="text-xs text-muted-foreground truncate">{tableName}</div>
)}
</div>
<Badge variant="outline" className={`bg-${themeColor}-100 text-${themeColor}-700 text-xs flex-shrink-0`}>
{themeBadge}
</Badge>
{/* 요약 정보 */}
<div className="text-xs text-muted-foreground flex-shrink-0">
{isMain ? (
columns.length > 0 && `${columns.length}개 컬럼`
) : (
<>
{hasFilterKey && `${(filterKeyMapping ? 1 : 0)}개 필터`}
{hasJoinRefs && hasFilterKey && " / "}
{hasJoinRefs && `${joinColumnRefs!.length}개 조인`}
</>
)}
</div>
</button>
{/* 펼쳐진 내용 */}
{isOpen && (
<div className={`border-t border-${themeColor}-100 p-3 space-y-3 bg-white/50`}>
{/* 필터 연결 정보 (필터 테이블만) */}
{!isMain && filterKeyMapping && (
<div className="flex items-center gap-1 text-xs">
<Badge variant="outline" className="h-4 px-1 text-[8px] bg-purple-200 text-purple-700 border-purple-300"></Badge>
<span className="font-mono text-purple-700">
{mainTable}.{filterKeyMapping.mainTableColumnLabel || filterKeyMapping.mainTableColumn}
</span>
<span className="text-muted-foreground">=</span>
<span className="font-mono text-purple-700">
{tableName}.{filterKeyMapping.filterTableColumnLabel || filterKeyMapping.filterTableColumn}
</span>
</div>
)}
{/* 테이블 컬럼 정보 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs font-medium text-gray-700">
<Table2 className="h-3 w-3" />
({loadingColumns ? "로딩중..." : `${columns.length}`})
</div>
{columnMappings.length > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-blue-500" />
<span> ({columnMappings.length})</span>
</div>
</div>
)}
</div>
{loadingColumns ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : sortedColumns.length > 0 ? (
<div className="flex gap-3 items-stretch">
{/* 왼쪽: 컬럼 목록 */}
<div className="flex-1 space-y-1 pr-1 max-h-[350px] overflow-y-auto">
{(() => {
// 드래그 중일 때 로컬 순서 적용
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 (
<div
key={cIdx}
draggable={canDrag}
onDragStart={canDrag ? (e) => 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" : ""}`}
>
<span className={`font-medium truncate flex-1 min-w-0 ${
isUsed ? "text-blue-700"
: isJoinKey ? "text-orange-800"
: isFilterKey ? "text-purple-800"
: "text-gray-500"
}`}>
{col.displayName || col.columnName}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
{isFilterKey && (
<Badge variant="outline" className="h-4 px-1 text-[8px] bg-purple-200 text-purple-700 border-purple-300"></Badge>
)}
{isJoinKey && (
<Badge variant="outline" className="h-4 px-1 text-[8px] bg-orange-200 text-orange-700 border-orange-300"></Badge>
)}
{isUsed && (
<Badge className="text-white text-[8px] px-1 py-0 h-4 bg-blue-500"></Badge>
)}
<span className="text-muted-foreground text-[10px] w-20 truncate text-right" title={col.dataType}>
{col.dataType?.split("(")[0]}
</span>
</div>
</div>
);
});
})()}
</div>
{/* 오른쪽: 컬럼 설정 패널 */}
<div className="w-52 border-l pl-3 flex-shrink-0 max-h-[350px] overflow-y-auto">
{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 (
<div className="space-y-3">
<div className="text-xs font-medium text-gray-700"> </div>
{/* 화면 필드 정보 (필드인 경우만) */}
{isUsed && (
<>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<div className="text-sm font-medium text-blue-700">
{selectedColumn?.displayName || selectedMapping?.columnName || editingField}
</div>
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<div className="text-sm font-mono text-gray-600">
{selectedMapping?.columnName || "-"}
</div>
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="w-full justify-between h-7 text-xs">
{selectedColumn?.displayName || selectedMapping?.columnName || "컬럼 선택"}
<ChevronsUpDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-48" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList className="max-h-32">
<CommandEmpty className="text-xs py-2"></CommandEmpty>
<CommandGroup>
{columns.map((c) => (
<CommandItem
key={c.columnName}
value={c.displayName || c.columnName}
onSelect={() => {
if (onColumnChange && selectedMapping) {
onColumnChange(editingField, selectedMapping.columnName, c.columnName);
}
setEditingField(null);
}}
>
<Check className={`mr-2 h-3 w-3 ${c.columnName.toLowerCase() === selectedMapping?.columnName?.toLowerCase() ? "opacity-100" : "opacity-0"}`} />
{c.displayName || c.columnName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 필드에서 제거 */}
<Button
variant="outline"
size="sm"
className="w-full h-7 text-xs text-red-600 border-red-300 hover:bg-red-50"
onClick={() => {
if (selectedMapping && onColumnChange) {
onColumnChange(selectedMapping.fieldLabel!, selectedMapping.columnName, "__REMOVE_FIELD__");
toast.success(`"${selectedColumn?.displayName || selectedMapping.columnName}" 필드가 제거되었습니다.`);
setEditingField(null);
}
}}
>
<X className="h-3 w-3 mr-1" />
</Button>
</>
)}
{/* 컬럼 기본 정보 (필드가 아닌 경우) */}
{!isUsed && (
<div className="space-y-3">
<div className="text-xs text-muted-foreground">
<div className="space-y-1">
<span className="text-[10px]"></span>
<div className="font-mono">{selectedColumn?.columnName || editingField}</div>
</div>
<div className="space-y-1 mt-2">
<span className="text-[10px]"> </span>
<div>{selectedColumn?.dataType || "-"}</div>
</div>
</div>
<Button
variant="outline"
size="sm"
className="w-full h-7 text-xs text-blue-600 border-blue-300 hover:bg-blue-50"
onClick={() => {
if (selectedColumn?.columnName && onColumnChange) {
onColumnChange("__NEW_FIELD__", "", selectedColumn.columnName);
toast.success(`"${selectedColumn.displayName || selectedColumn.columnName}" 필드가 추가되었습니다.`);
setEditingField(null);
}
}}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
)}
{/* 조인 설정 */}
<div className="space-y-2 pt-2 border-t border-orange-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<Badge variant="outline" className={`h-4 px-1 text-[8px] ${hasJoinSetting ? "bg-orange-200 text-orange-700 border-orange-300" : "bg-gray-100 text-gray-600 border-gray-300"}`}>
</Badge>
<span className={`text-[10px] font-medium ${hasJoinSetting ? "text-orange-700" : "text-gray-600"}`}>
{editingJoin && editingJoin.columnName === (selectedColumn?.columnName || editingField) ? "연결 편집" : (hasJoinSetting ? "연결 정보" : "연결 설정")}
</span>
</div>
{editingJoin && editingJoin.columnName === (selectedColumn?.columnName || editingField) ? (
<div className="flex gap-1">
<Button variant="ghost" size="sm" className="h-5 px-1.5 text-[10px] text-gray-500 hover:text-gray-700" onClick={() => setEditingJoin(null)}>
</Button>
<Button size="sm" className="h-5 px-1.5 text-[10px] bg-orange-500 hover:bg-orange-600 text-white" onClick={handleSaveJoinSetting} disabled={!editingJoin.referenceTable || !editingJoin.referenceColumn || savingJoinSetting}>
{savingJoinSetting ? "..." : "저장"}
</Button>
</div>
) : (
<Button
variant="ghost"
size="sm"
className={`h-5 px-1 text-[10px] ${hasJoinSetting ? "text-orange-600 hover:text-orange-800" : "text-gray-500 hover:text-gray-700"}`}
onClick={() => startEditingJoin(
selectedColumn?.columnName || editingField,
isJoinKey && joinRef ? joinRef.refTable : (selectedColumn?.referenceTable || ""),
isJoinKey && joinRef ? joinRef.refColumn : (selectedColumn?.referenceColumn || ""),
selectedColumn?.displayColumn || ""
)}
>
<Settings2 className="h-3 w-3 mr-0.5" />
{hasJoinSetting ? "편집" : "추가"}
</Button>
)}
</div>
{editingJoin && editingJoin.columnName === (selectedColumn?.columnName || editingField) ? (
<JoinSettingEditor
editingJoin={editingJoin}
setEditingJoin={setEditingJoin}
allTables={allTables}
refTableColumns={refTableColumns}
loadingRefColumns={loadingRefColumns}
savingJoinSetting={savingJoinSetting}
loadRefTableColumns={loadRefTableColumns}
handleSaveJoinSetting={handleSaveJoinSetting}
/>
) : hasJoinSetting ? (
<div className="space-y-1.5 text-xs">
<div>
<span className="text-muted-foreground"> : </span>
<span className="font-mono text-orange-800">{isJoinKey && joinRef ? joinRef.refTable : selectedColumn?.referenceTable}</span>
</div>
<div>
<span className="text-muted-foreground"> : </span>
<span className="font-mono text-orange-800">{isJoinKey && joinRef ? joinRef.refColumn : selectedColumn?.referenceColumn}</span>
</div>
</div>
) : (
<div className="text-[10px] text-muted-foreground"> .</div>
)}
</div>
{/* 필터 정보 (필터 키인 경우) - 필터 테이블에서만 */}
{!isMain && isFilterKey && filterKeyMapping && (
<div className="space-y-2 pt-2 border-t border-purple-200">
<div className="flex items-center gap-1">
<Badge variant="outline" className="h-4 px-1 text-[8px] bg-purple-200 text-purple-700 border-purple-300"></Badge>
<span className="text-[10px] text-purple-700 font-medium"> </span>
</div>
<div className="space-y-1.5 text-xs">
<div>
<span className="text-muted-foreground"> : </span>
<span className="font-mono text-purple-800">{mainTable}</span>
</div>
<div>
<span className="text-muted-foreground"> : </span>
<span className="font-mono text-purple-800">{filterKeyMapping.mainTableColumn}</span>
</div>
</div>
</div>
)}
</div>
);
})() : (
<div className="flex items-center justify-center h-full text-xs text-muted-foreground">
</div>
)}
</div>
</div>
) : (
<div className="text-xs text-muted-foreground text-center py-2"> </div>
)}
</div>
</div>
)}
</div>
);
}
// ============================================================
// 조인 설정 편집 컴포넌트 (검색 가능한 Combobox 사용)
// ============================================================
interface JoinSettingEditorProps {
editingJoin: {
columnName: string;
referenceTable: string;
referenceColumn: string;
displayColumn: string;
};
setEditingJoin: React.Dispatch<React.SetStateAction<{
columnName: string;
referenceTable: string;
referenceColumn: string;
displayColumn: string;
} | null>>;
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 (
<div className="space-y-2">
{/* 대상 테이블 선택 - 검색 가능 Combobox */}
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Popover open={tableSearchOpen} onOpenChange={setTableSearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full justify-between h-7 text-xs font-normal"
>
{selectedTable?.displayName || editingJoin.referenceTable || "테이블 선택"}
<ChevronsUpDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-56" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-2"> .</CommandEmpty>
<CommandGroup>
{allTables.map(t => (
<CommandItem
key={t.tableName}
value={t.displayName || t.tableName}
onSelect={() => {
setEditingJoin({ ...editingJoin, referenceTable: t.tableName, referenceColumn: "", displayColumn: "" });
loadRefTableColumns(t.tableName);
setTableSearchOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
editingJoin.referenceTable === t.tableName ? "opacity-100" : "opacity-0"
)}
/>
{t.displayName || t.tableName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 연결 컬럼 선택 - 검색 가능 Combobox */}
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> (PK)</span>
<Popover open={refColSearchOpen} onOpenChange={setRefColSearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full justify-between h-7 text-xs font-normal"
disabled={!editingJoin.referenceTable || loadingRefColumns}
>
{loadingRefColumns ? "로딩중..." : (selectedRefCol?.displayName || editingJoin.referenceColumn || "컬럼 선택")}
<ChevronsUpDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-56" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-2"> .</CommandEmpty>
<CommandGroup>
{refTableColumns.map(c => (
<CommandItem
key={c.columnName}
value={c.displayName || c.columnName}
onSelect={() => {
setEditingJoin({ ...editingJoin, referenceColumn: c.columnName });
setRefColSearchOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
editingJoin.referenceColumn === c.columnName ? "opacity-100" : "opacity-0"
)}
/>
{c.displayName || c.columnName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 표시 컬럼 선택 - 검색 가능 Combobox */}
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Popover open={displayColSearchOpen} onOpenChange={setDisplayColSearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full justify-between h-7 text-xs font-normal"
disabled={!editingJoin.referenceTable || loadingRefColumns}
>
{selectedDisplayCol?.displayName || editingJoin.displayColumn || "컬럼 선택"}
<ChevronsUpDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-56" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-2"> .</CommandEmpty>
<CommandGroup>
{refTableColumns.map(c => (
<CommandItem
key={c.columnName}
value={c.displayName || c.columnName}
onSelect={() => {
setEditingJoin({ ...editingJoin, displayColumn: c.columnName });
setDisplayColSearchOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
editingJoin.displayColumn === c.columnName ? "opacity-100" : "opacity-0"
)}
/>
{c.displayName || c.columnName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
);
}
// ============================================================
// 탭 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<string>();
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 (
<div className="space-y-6">
{/* 기본 정보 카드 */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
<div className="rounded-lg border bg-blue-50 p-4">
<div className="text-2xl font-bold text-blue-600">{stats.tableCount}</div>
<div className="text-xs text-blue-700"> </div>
</div>
<div className="rounded-lg border bg-purple-50 p-4">
<div className="text-2xl font-bold text-purple-600">{stats.fieldCount}</div>
<div className="text-xs text-purple-700"> </div>
</div>
<div className="rounded-lg border bg-orange-50 p-4">
<div className="text-2xl font-bold text-orange-600">{stats.joinCount}</div>
<div className="text-xs text-orange-700"> </div>
</div>
<div className="rounded-lg border bg-green-50 p-4">
<div className="text-2xl font-bold text-green-600">{stats.filterCount}</div>
<div className="text-xs text-green-700"> </div>
</div>
<div className="rounded-lg border bg-pink-50 p-4">
<div className="text-2xl font-bold text-pink-600">{stats.flowCount}</div>
<div className="text-xs text-pink-700"> </div>
</div>
</div>
{/* 메인 테이블 (아코디언 형식) */}
<div className="space-y-2">
<h3 className="flex items-center gap-2 text-sm font-semibold">
<Database className="h-4 w-4 text-blue-500" />
</h3>
{mainTable ? (
<TableColumnAccordion
tableName={mainTable}
tableLabel={mainTableLabel}
tableType="main"
columnMappings={
// layoutItems에서 컬럼 매핑 정보 추출 (y 좌표 순서대로)
layoutItems
.slice()
.sort((a, b) => 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}
/>
) : (
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
.
</div>
)}
</div>
{/* 연결된 필터 테이블 (아코디언 형식) */}
<div className="space-y-2">
<h3 className="flex items-center gap-2 text-sm font-semibold">
<Link2 className="h-4 w-4 text-purple-500" />
({filterTables.length})
</h3>
{filterTables.length > 0 ? (
<div className="space-y-2">
{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 (
<TableColumnAccordion
key={`${ft.tableName}-${idx}`}
tableName={ft.tableName}
tableLabel={ft.tableLabel}
tableType="filter"
mainTable={mainTable}
filterKeyMapping={ft.filterKeyMapping}
joinColumnRefs={ft.joinColumnRefs}
columnMappings={filterTableColumnMappings}
onColumnChange={handleColumnChange}
onColumnReorder={(newOrder) => handleColumnReorder("filter", newOrder)}
onJoinSettingSaved={onRefresh}
/>
);
})}
</div>
) : (
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
.
</div>
)}
</div>
{/* 데이터 흐름 요약 */}
<div className="space-y-2">
<h3 className="flex items-center gap-2 text-sm font-semibold">
<GitBranch className="h-4 w-4 text-pink-500" />
({dataFlows.length})
</h3>
{dataFlows.length > 0 ? (
<div className="space-y-2">
{dataFlows.slice(0, 3).map((flow) => (
<div
key={flow.id}
className="flex items-center gap-2 rounded-lg border bg-pink-50/50 p-3 text-sm"
>
<Badge variant="outline" className="bg-pink-100 text-pink-700">
{flow.flow_type}
</Badge>
<span className="flex-1">{flow.description || "설명 없음"}</span>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground"> {flow.target_screen_id}</span>
</div>
))}
{dataFlows.length > 3 && (
<div className="text-center text-xs text-muted-foreground">
+{dataFlows.length - 3}
</div>
)}
</div>
) : (
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
.
</div>
)}
</div>
</div>
);
}
// ============================================================
// 탭 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<ColumnTypeInfo[]>([]);
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<string>();
componentColumns.forEach((comp) => {
comp.columns.forEach((col) => allColumns.add(col));
});
return allColumns.size;
}, [componentColumns]);
// 컬럼명 → 표시명 매핑 (테이블 컬럼에서 추출)
const columnDisplayMap = useMemo(() => {
const map: Record<string, string> = {};
tableColumns.forEach((tc) => {
map[tc.columnName] = tc.displayName || tc.columnName;
});
return map;
}, [tableColumns]);
// 컴포넌트 타입별 그룹핑 (기존 fieldMappings용)
const groupedMappings = useMemo(() => {
const grouped: Record<string, FieldMappingInfo[]> = {};
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 (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
{/* 화면 컴포넌트별 컬럼 사용 현황 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-xs text-muted-foreground">
{isEditMode
? "컬럼을 클릭하여 매핑을 변경할 수 있습니다."
: "각 컴포넌트에서 사용하는 테이블 컬럼을 확인합니다."}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{totalColumns}
</Badge>
<Button
variant={isEditMode ? "default" : "outline"}
size="sm"
onClick={() => setIsEditMode(!isEditMode)}
className="h-7 text-xs"
>
{isEditMode ? (
<>
<Eye className="mr-1 h-3 w-3" />
</>
) : (
<>
<Pencil className="mr-1 h-3 w-3" />
</>
)}
</Button>
</div>
</div>
{componentColumns.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
<Columns3 className="mx-auto mb-2 h-8 w-8" />
<p className="text-sm"> .</p>
</div>
) : (
<div className="space-y-3">
{componentColumns.map((comp, idx) => (
<div
key={idx}
className="rounded-lg border bg-white overflow-hidden"
>
{/* 컴포넌트 헤더 */}
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-b">
<div className="flex items-center gap-2">
<Table2 className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">
{comp.componentLabel || comp.componentKind}
</span>
<Badge variant="outline" className="text-[10px]">
{comp.componentKind}
</Badge>
</div>
<Badge variant="outline" className="text-xs">
{comp.columns.length}
</Badge>
</div>
{/* 필드 → 컬럼 매핑 테이블 */}
<div className="divide-y">
{/* 테이블 헤더 */}
<div className="grid grid-cols-[1fr_28px_1fr] gap-1 px-3 py-1.5 bg-gray-100 text-[10px] font-medium text-gray-500 uppercase">
<span> ( )</span>
<span></span>
<span> ()</span>
</div>
{/* 매핑 행들 */}
{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 (
<div
key={cIdx}
className={cn(
"grid grid-cols-[1fr_28px_1fr] gap-1 px-3 py-1.5 items-center text-xs",
isJoinColumn && "bg-orange-50"
)}
>
{/* 필드명 (화면 표시) */}
<div className="flex items-center gap-1.5 min-w-0">
<span className="font-medium text-gray-900 truncate">
{displayName}
</span>
{isJoinColumn && (
<Badge variant="outline" className="text-[9px] bg-orange-100 text-orange-700 px-1 py-0 flex-shrink-0">
</Badge>
)}
</div>
{/* 화살표 */}
<div className="flex justify-center">
<ArrowRight className="h-3 w-3 text-gray-400" />
</div>
{/* 컬럼명 (데이터베이스) */}
{isEditMode ? (
<Popover
open={isEditing && editPopoverOpen}
onOpenChange={(open) => {
if (open) {
setEditingColumn({ componentIdx: idx, columnIdx: cIdx, currentColumn: col });
} else {
setEditingColumn(null);
}
setEditPopoverOpen(open);
}}
>
<PopoverTrigger asChild>
<button
className={cn(
"flex items-center gap-1 px-2 py-0.5 rounded text-left min-w-0",
"hover:bg-blue-50 border border-transparent hover:border-blue-300",
"transition-colors cursor-pointer group"
)}
>
<code className="text-blue-700 font-mono text-[11px] truncate">{col}</code>
<Pencil className="h-3 w-3 text-gray-400 group-hover:text-blue-500 flex-shrink-0" />
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
className="text-xs"
/>
<CommandList>
<CommandEmpty className="text-xs py-2 text-center">
.
</CommandEmpty>
<CommandGroup heading="테이블 컬럼">
{loadingTableColumns ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
tableColumns.map((tableCol) => (
<CommandItem
key={tableCol.columnName}
value={tableCol.columnName}
onSelect={(value) => {
toast.info(`컬럼 변경: ${col}${value}`, {
description: "저장 기능은 아직 구현 중입니다."
});
setEditPopoverOpen(false);
setEditingColumn(null);
}}
className="text-xs"
>
<div className="flex flex-col">
<span className={cn(
"font-medium",
tableCol.columnName === col && "text-blue-600"
)}>
{tableCol.displayName || tableCol.columnName}
</span>
{tableCol.displayName && tableCol.displayName !== tableCol.columnName && (
<span className="text-[10px] text-muted-foreground font-mono">
{tableCol.columnName}
</span>
)}
</div>
{tableCol.columnName === col && (
<Check className="ml-auto h-3 w-3 text-blue-600" />
)}
</CommandItem>
))
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<code className="text-blue-700 font-mono text-[11px] truncate">{col}</code>
)}
</div>
);
})}
</div>
</div>
))}
</div>
)}
</div>
{/* 서브 테이블 연결 관계 (기존 fieldMappings) */}
{fieldMappings.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-xs text-muted-foreground">
.
</p>
</div>
<Badge variant="outline" className="text-xs">
{fieldMappings.length}
</Badge>
</div>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px] text-xs">#</TableHead>
<TableHead className="text-xs"> </TableHead>
<TableHead className="w-[60px] text-center text-xs"></TableHead>
<TableHead className="text-xs"> </TableHead>
<TableHead className="text-xs"> </TableHead>
<TableHead className="text-xs"> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fieldMappings.map((mapping, idx) => (
<TableRow key={idx}>
<TableCell className="text-xs text-muted-foreground">
{idx + 1}
</TableCell>
<TableCell className="text-xs font-medium">
<Badge variant="outline" className="bg-blue-50 text-blue-700">
{mainTable}.{mapping.targetField}
</Badge>
</TableCell>
<TableCell className="text-center">
<ArrowRight className="mx-auto h-3 w-3 text-gray-400" />
</TableCell>
<TableCell className="text-xs">
<Badge variant="outline" className="bg-purple-50 text-purple-700">
{mapping.sourceTable || "-"}
</Badge>
</TableCell>
<TableCell className="text-xs">
<Badge variant="outline" className="bg-green-50 text-green-700">
{mapping.sourceField}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{mapping.componentType || "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 컴포넌트 타입별 요약 */}
{componentTypes.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground"> </h4>
<div className="flex flex-wrap gap-2">
{componentTypes.map((type) => (
<Badge
key={type}
variant="outline"
className="gap-1 bg-gray-50"
>
{type}
<span className="rounded-full bg-gray-200 px-1.5 text-[10px]">
{groupedMappings[type].length}
</span>
</Badge>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
// ============================================================
// 탭 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<DataFlow | null>(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 (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-4">
{/* 입력 폼 */}
<div className="space-y-3 rounded-lg bg-muted/50 p-4">
<div className="text-sm font-medium">
{isEditing ? "데이터 흐름 수정" : "새 데이터 흐름 추가"}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<Label className="text-xs"> ID *</Label>
<Input
type="number"
value={formData.target_screen_id}
onChange={(e) =>
setFormData({ ...formData, target_screen_id: e.target.value })
}
placeholder="화면 ID"
className="h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<SearchableSelect
value={formData.action_type}
onValueChange={(v) => setFormData({ ...formData, action_type: v })}
options={[
{ value: "navigate", label: "화면 이동" },
{ value: "modal", label: "모달 열기" },
{ value: "callback", label: "콜백" },
{ value: "refresh", label: "새로고침" },
]}
placeholder="액션 선택"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<SearchableSelect
value={formData.flow_type}
onValueChange={(v) => setFormData({ ...formData, flow_type: v })}
options={[
{ value: "forward", label: "전달 (Forward)" },
{ value: "return", label: "반환 (Return)" },
{ value: "broadcast", label: "브로드캐스트" },
]}
placeholder="흐름 선택"
/>
</div>
</div>
<div>
<Label className="text-xs"></Label>
<Textarea
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="데이터 흐름에 대한 설명"
className="h-16 resize-none text-xs"
/>
</div>
<div className="flex justify-end gap-2">
{isEditing && (
<Button variant="outline" size="sm" onClick={resetForm}>
</Button>
)}
<Button size="sm" onClick={handleSave} className="gap-1">
<Save className="h-4 w-4" />
{isEditing ? "수정" : "추가"}
</Button>
</div>
</div>
{/* 목록 */}
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs"> </TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs"> </TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="w-[100px] text-xs"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dataFlows.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="py-8 text-center text-sm text-muted-foreground"
>
.
</TableCell>
</TableRow>
) : (
dataFlows.map((flow) => (
<TableRow key={flow.id}>
<TableCell className="text-xs font-medium">
{flow.target_screen_id}
</TableCell>
<TableCell className="text-xs">
<Badge variant="outline" className="text-xs">
{flow.action_type}
</Badge>
</TableCell>
<TableCell className="text-xs">
<Badge
variant="outline"
className={cn(
"text-xs",
flow.flow_type === "forward" && "bg-blue-50 text-blue-700",
flow.flow_type === "return" && "bg-green-50 text-green-700",
flow.flow_type === "broadcast" && "bg-purple-50 text-purple-700"
)}
>
{flow.flow_type}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{flow.description || "-"}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(flow)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => handleDelete(flow.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
// ============================================================
// 탭: 제어 관리
// ============================================================
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<ButtonControlInfo[]>([]);
const [externalCalls, setExternalCalls] = useState<ExternalCallConfig[]>([]);
const [flows, setFlows] = useState<FlowDefinition[]>([]);
const [loading, setLoading] = useState(false);
const [expandedButton, setExpandedButton] = useState<string | null>(null);
const [editingButton, setEditingButton] = useState<string | null>(null);
const [editedValues, setEditedValues] = useState<Record<string, any>>({});
// 테이블 목록 조회
const [tableList, setTableList] = useState<TableInfo[]>([]);
// 데이터 로드
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<string, string> = {
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 (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
);
}
return (
<div className="space-y-4">
{/* 버튼 액션 설정 */}
<div className="rounded-lg border">
<div className="flex items-center gap-2 border-b bg-muted/30 px-3 py-2">
<MousePointer className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="ml-auto text-[10px]">
{buttonControls.length}
</Badge>
</div>
<div className="max-h-[300px] overflow-y-auto p-2">
{buttonControls.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<MousePointer className="mb-2 h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground"> </p>
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-2">
{buttonControls.map((btn) => (
<div key={btn.id} className="rounded border bg-white">
{/* 버튼 헤더 */}
<div
className="flex cursor-pointer items-center gap-2 p-2 hover:bg-muted/30"
onClick={() => setExpandedButton(expandedButton === btn.id ? null : btn.id)}
>
{expandedButton === btn.id ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="text-sm font-medium">[{btn.label}]</span>
<Badge className={cn("h-5 text-[10px]", getActionTypeColor(btn.actionType))}>
{getActionTypeLabel(btn.actionType)}
</Badge>
{btn.targetTable && (
<span className="text-xs text-muted-foreground">
{btn.targetTable}
</span>
)}
{btn.hasDataflowControl && (
<Badge variant="outline" className="ml-auto h-5 text-[10px] border-purple-300 text-purple-600">
<Zap className="mr-1 h-2.5 w-2.5" />
</Badge>
)}
<Button
variant="ghost"
size="sm"
className="ml-auto h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
window.open(`/admin/screenMng/screenMngList?screenId=${screenId}`, "_blank");
}}
title="상세 설정 (화면 디자이너)"
>
<ExternalLink className="h-3 w-3" />
</Button>
</div>
{/* 버튼 상세 (확장 시) */}
{expandedButton === btn.id && (
<div className="border-t bg-muted/10 p-3">
<div className="space-y-3">
{/* 대상 테이블 */}
<div className="grid grid-cols-[100px_1fr] items-center gap-2">
<Label className="text-xs text-muted-foreground"> </Label>
{editingButton === btn.id ? (
<Select
value={editedValues[btn.id]?.targetTable || btn.targetTable || ""}
onValueChange={(val) => setEditedValues(prev => ({
...prev,
[btn.id]: { ...prev[btn.id], targetTable: val }
}))}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tableList.map((t) => (
<SelectItem key={t.tableName} value={t.tableName} className="text-xs">
{t.displayName || t.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="text-xs">
{btn.targetTable || <span className="text-muted-foreground"></span>}
</span>
)}
</div>
{/* 확인 메시지 */}
<div className="grid grid-cols-[100px_1fr] items-center gap-2">
<Label className="text-xs text-muted-foreground"> </Label>
{editingButton === btn.id ? (
<Input
value={editedValues[btn.id]?.confirmMessage ?? btn.confirmMessage ?? ""}
onChange={(e) => setEditedValues(prev => ({
...prev,
[btn.id]: { ...prev[btn.id], confirmMessage: e.target.value }
}))}
className="h-7 text-xs"
placeholder="예: 정말 저장하시겠습니까?"
/>
) : (
<span className="text-xs">
{btn.confirmMessage || <span className="text-muted-foreground"></span>}
</span>
)}
</div>
{/* 플로우 연동 */}
<div className="grid grid-cols-[100px_1fr] items-center gap-2">
<Label className="text-xs text-muted-foreground"> </Label>
{editingButton === btn.id ? (
<Select
value={editedValues[btn.id]?.linkedFlowId?.toString() || btn.linkedFlow?.id?.toString() || "none"}
onValueChange={(val) => setEditedValues(prev => ({
...prev,
[btn.id]: {
...prev[btn.id],
linkedFlowId: val === "none" ? null : parseInt(val)
}
}))}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="플로우 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-xs"> </SelectItem>
{flows.map((f) => (
<SelectItem key={f.id} value={f.id.toString()} className="text-xs">
{f.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="text-xs">
{btn.linkedFlow ? (
<Badge variant="outline" className="text-[10px]">
<Workflow className="mr-1 h-2.5 w-2.5" />
{btn.linkedFlow.name}
</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</span>
)}
</div>
{/* 편집/저장 버튼 */}
<div className="flex justify-end gap-2 pt-2">
{editingButton === btn.id ? (
<>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => {
setEditingButton(null);
setEditedValues(prev => {
const next = { ...prev };
delete next[btn.id];
return next;
});
}}
>
</Button>
<Button
size="sm"
className="h-7 text-xs"
onClick={() => handleSaveButton(btn.id)}
>
<Save className="mr-1 h-3 w-3" />
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setEditingButton(btn.id)}
>
<Pencil className="mr-1 h-3 w-3" />
</Button>
)}
</div>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
{/* 외부 연동 */}
<div className="rounded-lg border">
<div className="flex items-center gap-2 border-b bg-muted/30 px-3 py-2">
<Globe className="h-4 w-4 text-green-500" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="ml-auto text-[10px]">
{externalCalls.filter(e => e.is_active === "Y").length}
</Badge>
</div>
<div className="max-h-[200px] overflow-y-auto p-2">
{externalCalls.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Globe className="mb-2 h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground"> </p>
<Button
variant="link"
size="sm"
className="mt-1 h-6 text-xs"
onClick={() => window.open("/admin/automaticMng/exCallConfList", "_blank")}
>
</Button>
</div>
) : (
<div className="space-y-1.5">
{externalCalls.slice(0, 5).map((call) => (
<div
key={call.id}
className="flex items-center gap-2 rounded border bg-white p-2"
>
<Badge
variant="outline"
className={cn(
"h-5 text-[10px]",
call.call_type === "rest-api" && "border-blue-300 text-blue-600",
call.call_type === "webhook" && "border-orange-300 text-orange-600",
call.call_type === "email" && "border-purple-300 text-purple-600",
)}
>
{call.call_type}
</Badge>
<span className="flex-1 truncate text-xs">{call.config_name}</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => window.open(`/admin/automaticMng/exCallConfList?id=${call.id}`, "_blank")}
>
<ExternalLink className="h-3 w-3" />
</Button>
</div>
))}
{externalCalls.length > 5 && (
<div className="text-center">
<Button
variant="link"
size="sm"
className="h-6 text-xs"
onClick={() => window.open("/admin/automaticMng/exCallConfList", "_blank")}
>
+{externalCalls.length - 5}
</Button>
</div>
)}
</div>
)}
</div>
<div className="border-t p-2">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Info className="h-3 w-3" />
<span> </span>
</div>
</div>
</div>
{/* 플로우 연동 */}
<div className="rounded-lg border">
<div className="flex items-center gap-2 border-b bg-muted/30 px-3 py-2">
<Workflow className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="ml-auto text-[10px]">
{flows.length}
</Badge>
</div>
<div className="max-h-[200px] overflow-y-auto p-2">
{flows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Workflow className="mb-2 h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground"> </p>
<Button
variant="link"
size="sm"
className="mt-1 h-6 text-xs"
onClick={() => window.open("/admin/automaticMng/flowMgmtList", "_blank")}
>
</Button>
</div>
) : (
<div className="space-y-1.5">
{flows.slice(0, 5).map((flow) => (
<div
key={flow.id}
className="flex items-center gap-2 rounded border bg-white p-2"
>
<Badge variant="outline" className="h-5 text-[10px] border-purple-300 text-purple-600">
</Badge>
<span className="flex-1 truncate text-xs">{flow.name}</span>
<span className="text-[10px] text-muted-foreground">
{flow.tableName}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => window.open(`/admin/automaticMng/flowMgmtList?id=${flow.id}`, "_blank")}
>
<ExternalLink className="h-3 w-3" />
</Button>
</div>
))}
{flows.length > 5 && (
<div className="text-center">
<Button
variant="link"
size="sm"
className="h-6 text-xs"
onClick={() => window.open("/admin/automaticMng/flowMgmtList", "_blank")}
>
+{flows.length - 5}
</Button>
</div>
)}
</div>
)}
</div>
<div className="border-t p-2">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Info className="h-3 w-3" />
<span> </span>
</div>
</div>
</div>
</div>
);
}
// ============================================================
// 탭 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<string | null>(null);
const containerRef = useRef<HTMLDivElement>(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 (
<div className="flex min-h-0 flex-1 flex-col">
{/* 상단 툴바 (최소화) */}
<div className="flex h-7 shrink-0 items-center justify-between border-b px-2">
<div className="flex items-center gap-1.5">
<Eye className="h-3 w-3 text-blue-500" />
<span className="truncate text-xs font-medium">{screenName}</span>
<span className="text-[10px] text-muted-foreground">(: 확대/, 드래그: 이동)</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
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="새로고침"
>
<RefreshCw className={cn("h-3 w-3", loading && "animate-spin")} />
</Button>
<Button variant="ghost" size="sm" onClick={openInNewTab} className="h-5 w-5 p-0" title="새 탭에서 열기">
<ExternalLink className="h-3 w-3" />
</Button>
</div>
</div>
{/* iframe 영역 - Ctrl+휠로 확대/축소, 내부 버튼/목록 클릭 가능 */}
<div
ref={containerRef}
className="relative min-h-0 flex-1 overflow-hidden flex items-center justify-center bg-gray-100"
>
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/80">
<div className="text-center">
<Loader2 className="mx-auto h-8 w-8 animate-spin text-primary" />
<p className="mt-2 text-sm text-muted-foreground"> ...</p>
</div>
</div>
)}
{error ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
<span className="text-2xl"></span>
</div>
<p className="text-sm text-destructive">{error}</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => {
setError(null);
setLoading(true);
}}
>
</Button>
</div>
</div>
) : (
<TransformWrapper
initialScale={initialScale}
minScale={0.2}
maxScale={3}
centerOnInit={true}
wheel={{ step: 0.05 }}
panning={{ velocityDisabled: true }}
>
{({ state }) => (
<TransformComponent
wrapperStyle={{ width: "100%", height: "100%" }}
contentStyle={{ display: "flex", alignItems: "center", justifyContent: "center" }}
>
<div
className="relative"
style={{
width: `${designWidth}px`,
height: `${designHeight}px`,
}}
>
<iframe
key={iframeKey}
id="screen-preview-iframe"
src={previewUrl}
className="border-0 shadow-lg rounded bg-white pointer-events-none"
style={{
width: "100%",
height: "100%",
}}
onLoad={handleIframeLoad}
onError={handleIframeError}
title={`화면 프리뷰: ${screenName}`}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
/>
{/* 클릭/드래그 분리 오버레이 */}
<div
className="absolute inset-0"
style={{ cursor: "grab" }}
onMouseDown={(e) => {
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);
}}
/>
</div>
</TransformComponent>
)}
</TransformWrapper>
)}
</div>
</div>
);
}
export default ScreenSettingModal;