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

3634 lines
144 KiB
TypeScript
Raw Normal View History

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