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

4838 lines
203 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

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

"use client";
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import {
Database,
Link2,
GitBranch,
Columns3,
Eye,
Save,
Plus,
Minus,
Pencil,
Trash2,
RefreshCw,
Loader2,
Check,
ChevronsUpDown,
ExternalLink,
Table2,
ArrowRight,
Settings2,
ChevronDown,
ChevronRight,
Filter,
RotateCcw,
X,
Zap,
MousePointer,
Workflow,
} from "lucide-react";
import {
getDataFlows,
createDataFlow,
updateDataFlow,
deleteDataFlow,
DataFlow,
getMultipleScreenLayoutSummary,
LayoutItem,
getScreenGroup,
} from "@/lib/api/screenGroup";
import { tableManagementApi, ColumnTypeInfo, TableInfo, ColumnSettings } from "@/lib/api/tableManagement";
import { screenApi } from "@/lib/api/screen";
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
import { getNodeFlows, createNodeFlow, NodeFlow } from "@/lib/api/nodeFlows";
import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
import ScreenDesigner from "@/components/screen/ScreenDesigner";
import { TableSettingModal } from "@/components/screen/TableSettingModal";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// ============================================================
// 타입 정의
// ============================================================
interface FilterTableInfo {
tableName: string;
tableLabel?: string;
filterColumns?: string[];
// 필터 키 매핑 정보 (메인 테이블.컬럼 → 필터 테이블.컬럼)
filterKeyMapping?: {
mainTableColumn: string; // 메인 테이블의 컬럼 (leftColumn)
mainTableColumnLabel?: string;
filterTableColumn: string; // 필터 테이블의 컬럼 (foreignKey)
filterTableColumnLabel?: string;
};
joinColumnRefs?: Array<{
column: string;
refTable: string;
refTableLabel?: string;
refColumn: string;
}>;
}
interface FieldMappingInfo {
targetField: string;
sourceField: string;
sourceTable?: string;
sourceDisplayName?: string;
componentType?: string;
}
interface ScreenSettingModalProps {
isOpen: boolean;
onClose: () => void;
screenId: number;
screenName: string;
groupId?: number;
companyCode?: string; // 프리뷰용 회사 코드
mainTable?: string;
mainTableLabel?: string;
filterTables?: FilterTableInfo[];
fieldMappings?: FieldMappingInfo[];
componentCount?: number;
onSaveSuccess?: () => void;
}
// 검색 가능한 Select 컴포넌트
interface SearchableSelectProps {
value: string;
onValueChange: (value: string) => void;
options: Array<{ value: string; label: string; description?: string }>;
placeholder?: string;
disabled?: boolean;
className?: string;
}
function SearchableSelect({
value,
onValueChange,
options,
placeholder = "선택...",
disabled = false,
className,
}: SearchableSelectProps) {
const [open, setOpen] = useState(false);
const selectedOption = options.find((opt) => opt.value === value);
return (
<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 [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); // 화면 캔버스 크기
const [showDesignerModal, setShowDesignerModal] = useState(false); // 화면 디자이너 모달
const [showTableSettingModal, setShowTableSettingModal] = useState(false); // 테이블 설정 모달
const [tableSettingTarget, setTableSettingTarget] = useState<{ tableName: string; tableLabel?: string } | null>(null);
// 그룹 내 화면 목록 및 현재 선택된 화면
const [groupScreens, setGroupScreens] = useState<Array<{
screen_id: number;
screen_name: string;
screen_role?: string;
display_order?: number;
table_name?: string;
}>>([]);
const [currentScreenId, setCurrentScreenId] = useState(screenId);
const [currentScreenName, setCurrentScreenName] = useState(screenName);
const [currentMainTable, setCurrentMainTable] = useState(mainTable);
const [currentMainTableLabel, setCurrentMainTableLabel] = useState(mainTableLabel);
// 그룹 내 화면 목록 로드
const loadGroupScreens = useCallback(async () => {
if (!groupId) return;
try {
const groupRes = await getScreenGroup(groupId);
if (groupRes.success && groupRes.data) {
const groupData = groupRes.data as any;
const screens = groupData.screens || [];
// display_order 순으로 정렬
screens.sort((a: any, b: any) => (a.display_order || 0) - (b.display_order || 0));
setGroupScreens(screens);
}
} catch (error) {
console.error("그룹 화면 목록 로드 실패:", error);
}
}, [groupId]);
// 화면 선택 변경 핸들러
const handleScreenChange = useCallback(async (newScreenId: number) => {
const selectedScreen = groupScreens.find(s => s.screen_id === newScreenId);
if (!selectedScreen) return;
setCurrentScreenId(newScreenId);
setCurrentScreenName(selectedScreen.screen_name);
setCurrentMainTable(selectedScreen.table_name);
// 테이블 라벨은 별도 조회 필요 (일단 테이블명 사용)
setCurrentMainTableLabel(selectedScreen.table_name);
setIframeKey(prev => prev + 1); // iframe 새로고침
}, [groupScreens]);
// 테이블 설정 모달 열기 핸들러
const handleOpenTableSetting = useCallback((tableName: string, tableLabel?: string) => {
setTableSettingTarget({ tableName, tableLabel });
setShowTableSettingModal(true);
}, []);
// 테이블 설정 모달 닫기 핸들러
const handleCloseTableSetting = useCallback(() => {
setShowTableSettingModal(false);
setTableSettingTarget(null);
}, []);
// 초기 로드 시 그룹 화면 목록도 로드
useEffect(() => {
if (isOpen && groupId) {
loadGroupScreens();
}
}, [isOpen, groupId, loadGroupScreens]);
// props 변경 시 현재 화면 상태 업데이트
useEffect(() => {
setCurrentScreenId(screenId);
setCurrentScreenName(screenName);
setCurrentMainTable(mainTable);
setCurrentMainTableLabel(mainTableLabel);
}, [screenId, screenName, mainTable, mainTableLabel]);
// 데이터 로드
const loadData = useCallback(async () => {
if (!currentScreenId) return;
setLoading(true);
try {
// 1. 해당 화면에서 시작하는 데이터 흐름 로드
const flowsResponse = await getDataFlows({ sourceScreenId: currentScreenId });
if (flowsResponse.success && flowsResponse.data) {
setDataFlows(flowsResponse.data);
}
// 2. 화면 레이아웃 요약 정보 로드 (컴포넌트 컬럼 정보 포함)
const layoutResponse = await getMultipleScreenLayoutSummary([currentScreenId]);
if (layoutResponse.success && layoutResponse.data) {
const screenLayout = layoutResponse.data[currentScreenId];
setLayoutItems(screenLayout?.layoutItems || []);
// 캔버스 크기 저장 (화면 프리뷰에 사용)
setCanvasSize({
width: screenLayout?.canvasWidth || 0,
height: screenLayout?.canvasHeight || 0,
});
}
} catch (error) {
console.error("데이터 로드 실패:", error);
} finally {
setLoading(false);
}
}, [currentScreenId]);
useEffect(() => {
if (isOpen && currentScreenId) {
loadData();
}
}, [isOpen, currentScreenId, loadData]);
// 새로고침 (데이터 + iframe)
const handleRefresh = useCallback(() => {
loadData();
setIframeKey(prev => prev + 1); // iframe 새로고침
}, [loadData]);
return (
<>
<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" />
:
{groupScreens.length > 1 ? (
<Select
value={currentScreenId.toString()}
onValueChange={(value) => handleScreenChange(parseInt(value, 10))}
>
<SelectTrigger className="h-8 w-auto min-w-[200px] max-w-[400px] text-base font-semibold">
<SelectValue placeholder="화면 선택" />
</SelectTrigger>
<SelectContent>
{groupScreens.map((screen) => (
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
{screen.screen_name}
{screen.screen_role && (
<span className="ml-2 text-xs text-muted-foreground">
({screen.screen_role})
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span>{currentScreenName}</span>
)}
</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>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
className="h-7 w-7 p-0"
title="새로고침"
>
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowDesignerModal(true)}
className="h-7 px-2 text-xs gap-1"
title="화면 디자이너에서 상세 편집"
>
<ExternalLink className="h-3 w-3" />
</Button>
</div>
</div>
{/* 탭 1: 화면 개요 */}
<TabsContent value="overview" className="mt-0 min-h-0 flex-1 overflow-auto p-3">
<OverviewTab
screenId={currentScreenId}
screenName={currentScreenName}
mainTable={currentMainTable}
mainTableLabel={currentMainTableLabel}
filterTables={filterTables}
fieldMappings={fieldMappings}
componentCount={componentCount}
dataFlows={dataFlows}
layoutItems={layoutItems}
loading={loading}
onRefresh={handleRefresh}
onOpenTableSetting={handleOpenTableSetting}
/>
</TabsContent>
{/* 탭 2: 제어 관리 */}
<TabsContent value="control-management" className="mt-0 min-h-0 flex-1 overflow-auto p-3">
<ControlManagementTab
screenId={currentScreenId}
groupId={groupId}
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={currentScreenId}
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={currentScreenId}
screenName={currentScreenName}
companyCode={companyCode}
iframeKey={iframeKey}
canvasWidth={canvasSize.width}
canvasHeight={canvasSize.height}
/>
</div>
</div>
</DialogContent>
</Dialog>
{/* ScreenDesigner 전체 화면 모달 */}
<Dialog open={showDesignerModal} onOpenChange={setShowDesignerModal}>
<DialogContent className="max-w-[98vw] h-[95vh] p-0 overflow-hidden">
<div className="flex flex-col h-full">
<ScreenDesigner
selectedScreen={{
screenId: currentScreenId,
screenCode: `screen_${currentScreenId}`,
screenName: currentScreenName,
tableName: currentMainTable || "",
companyCode: companyCode || "*",
description: "",
isActive: "Y" as const,
createdDate: new Date(),
updatedDate: new Date(),
}}
onBackToList={async () => {
setShowDesignerModal(false);
// 디자이너에서 저장 후 모달 닫으면 데이터 새로고침
await loadData();
// 데이터 로드 완료 후 iframe 갱신
setIframeKey(prev => prev + 1);
}}
/>
</div>
</DialogContent>
</Dialog>
{/* TableSettingModal */}
{tableSettingTarget && (
<TableSettingModal
isOpen={showTableSettingModal}
onClose={handleCloseTableSetting}
tableName={tableSettingTarget.tableName}
tableLabel={tableSettingTarget.tableLabel}
screenId={currentScreenId}
onSaveSuccess={() => {
handleRefresh();
}}
/>
)}
</>
);
}
// ============================================================
// 통합 테이블 컬럼 아코디언 컴포넌트
// ============================================================
interface ColumnMapping {
columnName: string;
fieldLabel?: string;
order: number; // 화면 순서 (y 좌표 기준)
}
interface JoinColumnRef {
column: string;
refTable: string;
refTableLabel?: string;
refColumn: string;
displayColumn?: string;
}
interface FilterKeyMapping {
mainTableColumn: string;
mainTableColumnLabel?: string;
filterTableColumn: string;
filterTableColumnLabel?: string;
}
interface TableColumnAccordionProps {
// 공통 props
tableName: string;
tableLabel?: string;
tableType: "main" | "filter"; // 테이블 타입
columnMappings?: ColumnMapping[];
onColumnChange?: (fieldLabel: string, oldColumn: string, newColumn: string) => void;
onColumnReorder?: (newOrder: string[]) => void; // 컬럼 순서 변경 콜백
onJoinSettingSaved?: () => void;
usedFields?: Set<string>; // 화면에서 사용 중인 컬럼 목록
// 필터 테이블 전용 props (optional)
mainTable?: string; // 메인 테이블명 (필터 테이블에서 필터 연결 정보 표시용)
filterKeyMapping?: FilterKeyMapping;
joinColumnRefs?: JoinColumnRef[];
}
function TableColumnAccordion({
tableName,
tableLabel,
tableType,
columnMappings = [],
onColumnChange,
onColumnReorder,
onJoinSettingSaved,
usedFields = new Set(),
mainTable,
filterKeyMapping,
joinColumnRefs = [],
}: TableColumnAccordionProps) {
// columnMappings를 Map으로 변환 (컬럼명 → 매핑정보)
const columnMappingMap = useMemo(() => {
const map = new Map<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);
// usedFields에서도 확인 (bindField 등에서 가져온 사용 컬럼)
const isUsed = !!mapping || usedFields.has(colNameLower) ||
Array.from(usedFields).some(f => f.toLowerCase() === colNameLower);
return { isFilterKey, isJoinKey, joinRef, isUsed, mapping };
};
const ThemeIcon = themeIcon;
return (
<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; // 컬럼 변경 후 새로고침 콜백
onOpenTableSetting?: (tableName: string, tableLabel?: string) => void; // 테이블 설정 모달 열기
}
function OverviewTab({
screenId,
screenName,
mainTable,
mainTableLabel,
filterTables,
fieldMappings,
componentCount,
dataFlows,
layoutItems,
loading,
onRefresh,
onOpenTableSetting,
}: OverviewTabProps) {
const [isSavingColumn, setIsSavingColumn] = useState(false);
// 컬럼 변경 저장 함수 - 화면 디자이너와 동일한 방식
const handleColumnChange = useCallback(async (fieldLabel: string, oldColumn: string, newColumn: string) => {
console.log("[handleColumnChange] 시작", { screenId, fieldLabel, oldColumn, newColumn });
if (!screenId) {
toast.error("화면 정보가 없습니다.");
return;
}
// 필드 추가/제거 처리
const isAddingField = fieldLabel === "__NEW_FIELD__";
const isRemovingField = newColumn === "__REMOVE_FIELD__";
setIsSavingColumn(true);
try {
// 1. 현재 레이아웃 가져오기
console.log("[handleColumnChange] 레이아웃 조회 시작", { screenId });
const currentLayout = await screenApi.getLayout(screenId);
console.log("[handleColumnChange] 레이아웃 조회 완료", {
hasLayout: !!currentLayout,
hasComponents: !!currentLayout?.components,
componentCount: currentLayout?.components?.length
});
if (!currentLayout?.components) {
toast.error("레이아웃 정보를 불러올 수 없습니다.");
console.error("[handleColumnChange] 레이아웃 정보 없음", { currentLayout });
return;
}
// 2. 레이아웃에서 해당 컬럼 변경
let columnChanged = false;
// 디버깅: 각 컴포넌트의 구조 확인
console.log("[handleColumnChange] 컴포넌트 구조 분석 시작");
currentLayout.components.forEach((comp: any, i: number) => {
console.log(`[handleColumnChange] 컴포넌트 ${i}:`, {
id: comp.id,
componentType: comp.componentType,
hasUsedColumns: !!comp.usedColumns,
usedColumns: comp.usedColumns,
hasComponentConfig: !!comp.componentConfig,
componentConfigKeys: comp.componentConfig ? Object.keys(comp.componentConfig) : [],
componentConfigColumns: comp.componentConfig?.columns,
componentConfigUsedColumns: comp.componentConfig?.usedColumns,
columnName: comp.columnName,
bindField: comp.bindField,
});
});
let updatedComponents = currentLayout.components.map((comp: any) => {
// usedColumns 배열이 있는 컴포넌트에서 oldColumn을 newColumn으로 교체
if (comp.usedColumns && Array.isArray(comp.usedColumns)) {
// 필드 추가
if (isAddingField) {
console.log("[handleColumnChange] usedColumns에 필드 추가", { compId: comp.id, newColumn });
columnChanged = true;
return {
...comp,
usedColumns: [...comp.usedColumns, newColumn],
};
}
const idx = comp.usedColumns.findIndex(
(col: string) => col.toLowerCase() === oldColumn.toLowerCase()
);
if (idx !== -1) {
console.log("[handleColumnChange] usedColumns에서 찾음", { compId: comp.id, idx, isRemovingField });
columnChanged = true;
// 필드 제거
if (isRemovingField) {
return {
...comp,
usedColumns: comp.usedColumns.filter((_: string, i: number) => i !== idx),
};
}
// 컬럼 변경
return {
...comp,
usedColumns: comp.usedColumns.map((col: string, i: number) =>
i === idx ? newColumn : col
),
};
}
}
// componentConfig 내부의 usedColumns도 확인
if (comp.componentConfig?.usedColumns && Array.isArray(comp.componentConfig.usedColumns)) {
// 필드 추가
if (isAddingField && !columnChanged) {
console.log("[handleColumnChange] componentConfig.usedColumns에 필드 추가", { compId: comp.id, newColumn });
columnChanged = true;
return {
...comp,
componentConfig: {
...comp.componentConfig,
usedColumns: [...comp.componentConfig.usedColumns, newColumn],
},
};
}
const idx = comp.componentConfig.usedColumns.findIndex(
(col: string) => col.toLowerCase() === oldColumn.toLowerCase()
);
if (idx !== -1) {
console.log("[handleColumnChange] componentConfig.usedColumns에서 찾음", { compId: comp.id, idx, isRemovingField });
columnChanged = true;
// 필드 제거
if (isRemovingField) {
return {
...comp,
componentConfig: {
...comp.componentConfig,
usedColumns: comp.componentConfig.usedColumns.filter((_: string, i: number) => i !== idx),
},
};
}
// 컬럼 변경
return {
...comp,
componentConfig: {
...comp.componentConfig,
usedColumns: comp.componentConfig.usedColumns.map((col: string, i: number) =>
i === idx ? newColumn : col
),
},
};
}
}
// componentConfig.columns 배열도 확인 (컬럼 설정 형태)
if (comp.componentConfig?.columns && Array.isArray(comp.componentConfig.columns)) {
// 필드 추가
if (isAddingField && !columnChanged) {
console.log("[handleColumnChange] componentConfig.columns에 필드 추가", { compId: comp.id, newColumn });
columnChanged = true;
return {
...comp,
componentConfig: {
...comp.componentConfig,
columns: [...comp.componentConfig.columns, { field: newColumn, columnName: newColumn }],
},
};
}
const columnIdx = comp.componentConfig.columns.findIndex(
(col: any) => {
const colName = typeof col === 'string' ? col : (col.field || col.columnName || col.name);
return colName?.toLowerCase() === oldColumn.toLowerCase();
}
);
if (columnIdx !== -1) {
console.log("[handleColumnChange] componentConfig.columns에서 찾음", { compId: comp.id, columnIdx, isRemovingField });
columnChanged = true;
// 필드 제거
if (isRemovingField) {
return {
...comp,
componentConfig: {
...comp.componentConfig,
columns: comp.componentConfig.columns.filter((_: any, i: number) => i !== columnIdx),
},
};
}
// 컬럼 변경
const updatedColumns = comp.componentConfig.columns.map((col: any, i: number) => {
if (i !== columnIdx) return col;
if (typeof col === 'string') return newColumn;
return { ...col, field: newColumn, columnName: newColumn };
});
return {
...comp,
componentConfig: {
...comp.componentConfig,
columns: updatedColumns,
},
};
}
}
// columnName 필드 체크 (위젯 컴포넌트)
if (comp.columnName?.toLowerCase() === oldColumn.toLowerCase()) {
console.log("[handleColumnChange] columnName에서 찾음", { compId: comp.id });
columnChanged = true;
return {
...comp,
columnName: newColumn,
};
}
// bindField 필드 체크 (바인딩 필드)
if (comp.bindField?.toLowerCase() === oldColumn.toLowerCase()) {
console.log("[handleColumnChange] bindField에서 찾음", { compId: comp.id });
columnChanged = true;
return {
...comp,
bindField: newColumn,
};
}
// split-panel-layout의 leftPanel.columns 검사
if (comp.componentConfig?.leftPanel?.columns && Array.isArray(comp.componentConfig.leftPanel.columns)) {
const leftColumns = comp.componentConfig.leftPanel.columns;
console.log("[handleColumnChange] leftPanel.columns 검사:", {
compId: comp.id,
leftColumnsCount: leftColumns.length,
leftColumnsContent: leftColumns.map((col: any) => typeof col === 'string' ? col : (col.name || col.columnName || col.field)),
searchingFor: isAddingField ? newColumn : oldColumn.toLowerCase(),
isAddingField,
isRemovingField,
});
// 필드 추가: 배열에 새 컬럼 추가
if (isAddingField) {
console.log("[handleColumnChange] 필드 추가", { compId: comp.id, newColumn });
columnChanged = true;
return {
...comp,
componentConfig: {
...comp.componentConfig,
leftPanel: {
...comp.componentConfig.leftPanel,
columns: [...leftColumns, { name: newColumn, columnName: newColumn }],
},
},
};
}
const columnIdx = leftColumns.findIndex((col: any) => {
const colName = typeof col === 'string' ? col : (col.name || col.columnName || col.field);
return colName?.toLowerCase() === oldColumn.toLowerCase();
});
if (columnIdx !== -1) {
console.log("[handleColumnChange] leftPanel.columns에서 찾음", { compId: comp.id, columnIdx, isRemovingField });
columnChanged = true;
// 필드 제거: 배열에서 해당 컬럼 제거
if (isRemovingField) {
const filteredColumns = leftColumns.filter((_: any, i: number) => i !== columnIdx);
return {
...comp,
componentConfig: {
...comp.componentConfig,
leftPanel: {
...comp.componentConfig.leftPanel,
columns: filteredColumns,
},
},
};
}
// 컬럼 변경
const updatedLeftColumns = leftColumns.map((col: any, i: number) => {
if (i !== columnIdx) return col;
if (typeof col === 'string') return newColumn;
// 객체인 경우 name/columnName 필드 업데이트
return { ...col, name: newColumn, columnName: newColumn };
});
return {
...comp,
componentConfig: {
...comp.componentConfig,
leftPanel: {
...comp.componentConfig.leftPanel,
columns: updatedLeftColumns,
},
},
};
}
}
// split-panel-layout의 rightPanel.columns 검사
if (comp.componentConfig?.rightPanel?.columns && Array.isArray(comp.componentConfig.rightPanel.columns)) {
const rightColumns = comp.componentConfig.rightPanel.columns;
// 필드 추가: 배열에 새 컬럼 추가
if (isAddingField && !columnChanged) {
console.log("[handleColumnChange] 필드 추가 (rightPanel)", { compId: comp.id, newColumn });
columnChanged = true;
return {
...comp,
componentConfig: {
...comp.componentConfig,
rightPanel: {
...comp.componentConfig.rightPanel,
columns: [...rightColumns, { name: newColumn, columnName: newColumn }],
},
},
};
}
const columnIdx = rightColumns.findIndex((col: any) => {
const colName = typeof col === 'string' ? col : (col.name || col.columnName || col.field);
return colName?.toLowerCase() === oldColumn.toLowerCase();
});
if (columnIdx !== -1) {
console.log("[handleColumnChange] rightPanel.columns에서 찾음", { compId: comp.id, columnIdx, isRemovingField });
columnChanged = true;
// 필드 제거
if (isRemovingField) {
const filteredColumns = rightColumns.filter((_: any, i: number) => i !== columnIdx);
return {
...comp,
componentConfig: {
...comp.componentConfig,
rightPanel: {
...comp.componentConfig.rightPanel,
columns: filteredColumns,
},
},
};
}
// 컬럼 변경
const updatedRightColumns = rightColumns.map((col: any, i: number) => {
if (i !== columnIdx) return col;
if (typeof col === 'string') return newColumn;
return { ...col, name: newColumn, columnName: newColumn };
});
return {
...comp,
componentConfig: {
...comp.componentConfig,
rightPanel: {
...comp.componentConfig.rightPanel,
columns: updatedRightColumns,
},
},
};
}
}
return comp;
});
// 폼 화면용 필드 추가/제거 처리 (개별 input 컴포넌트)
if (!columnChanged) {
// 폼 화면 필드 추가: 새 text-input 컴포넌트 생성
if (isAddingField && newColumn) {
console.log("[handleColumnChange] 폼 화면 필드 추가 시도", { newColumn });
// 마지막 컴포넌트 위치 계산
let maxY = 50; // 기본 시작 위치
let lastComponentHeight = 30;
currentLayout.components.forEach((comp: any) => {
const compY = comp.position?.y || 0;
const compHeight = comp.size?.height || 30;
if (compY + compHeight > maxY) {
maxY = compY + compHeight;
lastComponentHeight = compHeight;
}
});
// 새 컴포넌트 위치: 마지막 컴포넌트 아래 + 간격
const newY = maxY + 10;
const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// 새 text-input 컴포넌트 생성
const newComponent = {
id: newComponentId,
type: "component",
label: newColumn,
columnName: newColumn,
bindField: newColumn,
widgetType: "text-input",
componentType: "text-input",
position: { x: 20, y: newY, z: 1 },
size: { width: 300, height: 30 },
gridColumns: 4,
componentConfig: {
type: "text-input",
webType: "text-input",
placeholder: `${newColumn}을(를) 입력하세요`,
},
webTypeConfig: {},
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#212121",
width: "300px",
height: "30px",
},
};
updatedComponents = [...updatedComponents, newComponent];
columnChanged = true;
console.log("[handleColumnChange] 폼 화면 필드 추가 완료", { newComponentId, newY });
}
// 폼 화면 필드 제거: bindField가 일치하는 컴포넌트 삭제
if (isRemovingField && oldColumn) {
console.log("[handleColumnChange] 폼 화면 필드 제거 시도", { oldColumn });
const beforeCount = updatedComponents.length;
updatedComponents = updatedComponents.filter((comp: any) => {
// bindField, columnName, 또는 properties.columnName으로 매칭
const compBindField = comp.bindField || comp.columnName || comp.properties?.columnName;
if (compBindField?.toLowerCase() === oldColumn.toLowerCase()) {
console.log("[handleColumnChange] 폼 컴포넌트 제거", { compId: comp.id, compBindField });
return false; // 제거
}
return true; // 유지
});
if (beforeCount > updatedComponents.length) {
columnChanged = true;
console.log("[handleColumnChange] 폼 화면 필드 제거 완료", {
beforeCount,
afterCount: updatedComponents.length
});
}
}
}
if (!columnChanged) {
toast.warning("변경할 컬럼을 찾을 수 없습니다.");
console.warn("[handleColumnChange] 변경할 컬럼 없음", { oldColumn, newColumn });
return;
}
// 3. 저장
console.log("[handleColumnChange] 저장 시작", {
screenId,
componentCount: updatedComponents.length
});
await screenApi.saveLayout(screenId, {
...currentLayout,
components: updatedComponents,
});
console.log("[handleColumnChange] 저장 완료");
if (isAddingField) {
toast.success(`필드가 추가되었습니다: ${newColumn}`);
} else if (isRemovingField) {
toast.success(`필드가 제거되었습니다: ${oldColumn}`);
} else {
toast.success(`컬럼이 변경되었습니다: ${oldColumn}${newColumn}`);
}
// 실시간 반영을 위해 콜백 호출
onRefresh?.();
} catch (error) {
console.error("컬럼 변경 저장 실패:", error);
toast.error("컬럼 변경 저장에 실패했습니다.");
} finally {
setIsSavingColumn(false);
}
}, [screenId, onRefresh]);
// 컬럼 순서 변경 저장 함수
const handleColumnReorder = useCallback(async (tableType: "main" | "filter", newOrder: string[]) => {
console.log("[handleColumnReorder] 시작", { screenId, tableType, newOrder });
if (!screenId) {
console.warn("[handleColumnReorder] screenId 없음");
return;
}
try {
// 1. 현재 레이아웃 가져오기
const currentLayout = await screenApi.getLayout(screenId);
if (!currentLayout?.components) {
console.error("[handleColumnReorder] 레이아웃 정보 없음");
return;
}
// 2. 레이아웃에서 해당 컬럼들의 순서 변경
let orderChanged = false;
const updatedComponents = currentLayout.components.map((comp: any) => {
// split-panel-layout의 leftPanel.columns 순서 변경
if (comp.componentConfig?.leftPanel?.columns && Array.isArray(comp.componentConfig.leftPanel.columns)) {
const leftColumns = comp.componentConfig.leftPanel.columns as any[];
// newOrder에 따라 leftColumns 재정렬
const reorderedColumns = newOrder.map(colName => {
return leftColumns.find((col: any) => {
const name = typeof col === 'string' ? col : (col.name || col.columnName || col.field);
return name?.toLowerCase() === colName.toLowerCase();
});
}).filter(Boolean);
// 원래 없던 컬럼들 유지 (newOrder에 없는 컬럼들)
const remainingColumns = leftColumns.filter((col: any) => {
const name = typeof col === 'string' ? col : (col.name || col.columnName || col.field);
return !newOrder.some(n => n.toLowerCase() === name?.toLowerCase());
});
if (reorderedColumns.length > 0) {
orderChanged = true;
console.log("[handleColumnReorder] leftPanel.columns 순서 변경", {
compId: comp.id,
before: leftColumns.map((c: any) => typeof c === 'string' ? c : (c.name || c.columnName)),
after: [...reorderedColumns, ...remainingColumns].map((c: any) => typeof c === 'string' ? c : (c.name || c.columnName)),
});
return {
...comp,
componentConfig: {
...comp.componentConfig,
leftPanel: {
...comp.componentConfig.leftPanel,
columns: [...reorderedColumns, ...remainingColumns],
},
},
};
}
}
// rightPanel.columns 순서 변경
if (comp.componentConfig?.rightPanel?.columns && Array.isArray(comp.componentConfig.rightPanel.columns)) {
const rightColumns = comp.componentConfig.rightPanel.columns as any[];
const reorderedColumns = newOrder.map(colName => {
return rightColumns.find((col: any) => {
const name = typeof col === 'string' ? col : (col.name || col.columnName || col.field);
return name?.toLowerCase() === colName.toLowerCase();
});
}).filter(Boolean);
const remainingColumns = rightColumns.filter((col: any) => {
const name = typeof col === 'string' ? col : (col.name || col.columnName || col.field);
return !newOrder.some(n => n.toLowerCase() === name?.toLowerCase());
});
if (reorderedColumns.length > 0) {
orderChanged = true;
console.log("[handleColumnReorder] rightPanel.columns 순서 변경", {
compId: comp.id,
before: rightColumns.map((c: any) => typeof c === 'string' ? c : (c.name || c.columnName)),
after: [...reorderedColumns, ...remainingColumns].map((c: any) => typeof c === 'string' ? c : (c.name || c.columnName)),
});
return {
...comp,
componentConfig: {
...comp.componentConfig,
rightPanel: {
...comp.componentConfig.rightPanel,
columns: [...reorderedColumns, ...remainingColumns],
},
},
};
}
}
// componentConfig.usedColumns 순서 변경
if (comp.componentConfig?.usedColumns && Array.isArray(comp.componentConfig.usedColumns)) {
const usedColumns = comp.componentConfig.usedColumns as string[];
const reorderedColumns = newOrder.filter(colName =>
usedColumns.some(c => c.toLowerCase() === colName.toLowerCase())
);
const remainingColumns = usedColumns.filter(c =>
!newOrder.some(n => n.toLowerCase() === c.toLowerCase())
);
if (reorderedColumns.length > 0) {
orderChanged = true;
console.log("[handleColumnReorder] usedColumns 순서 변경", {
compId: comp.id,
before: usedColumns,
after: [...reorderedColumns, ...remainingColumns],
});
return {
...comp,
componentConfig: {
...comp.componentConfig,
usedColumns: [...reorderedColumns, ...remainingColumns],
},
};
}
}
// componentConfig.columns 순서 변경
if (comp.componentConfig?.columns && Array.isArray(comp.componentConfig.columns)) {
const columns = comp.componentConfig.columns as any[];
const reorderedColumns = newOrder.map(colName => {
return columns.find((col: any) => {
const name = typeof col === 'string' ? col : (col.field || col.columnName || col.name);
return name?.toLowerCase() === colName.toLowerCase();
});
}).filter(Boolean);
const remainingColumns = columns.filter((col: any) => {
const name = typeof col === 'string' ? col : (col.field || col.columnName || col.name);
return !newOrder.some(n => n.toLowerCase() === name?.toLowerCase());
});
if (reorderedColumns.length > 0) {
orderChanged = true;
console.log("[handleColumnReorder] componentConfig.columns 순서 변경", {
compId: comp.id,
before: columns.map((c: any) => typeof c === 'string' ? c : (c.field || c.columnName)),
after: [...reorderedColumns, ...remainingColumns].map((c: any) => typeof c === 'string' ? c : (c.field || c.columnName)),
});
return {
...comp,
componentConfig: {
...comp.componentConfig,
columns: [...reorderedColumns, ...remainingColumns],
},
};
}
}
return comp;
});
if (!orderChanged) {
console.log("[handleColumnReorder] 순서 변경 없음");
return;
}
// 3. 레이아웃 저장
console.log("[handleColumnReorder] 레이아웃 저장");
await screenApi.saveLayout(screenId, {
...currentLayout,
components: updatedComponents,
});
console.log("[handleColumnReorder] 순서 변경 저장 완료");
// 실시간 반영을 위해 콜백 호출
onRefresh?.();
} catch (error) {
console.error("[handleColumnReorder] 순서 변경 저장 실패:", error);
toast.error("컬럼 순서 변경 저장에 실패했습니다.");
}
}, [screenId, onRefresh]);
// 통계 계산 (layoutItems의 컬럼 수도 포함)
const stats = useMemo(() => {
const totalJoins = filterTables.reduce(
(sum, ft) => sum + (ft.joinColumnRefs?.length || 0),
0
);
const totalFilters = filterTables.reduce(
(sum, ft) => sum + (ft.filterColumns?.length || 0),
0
);
// layoutItems에서 사용하는 컬럼 수 계산 (usedColumns + bindField)
const layoutColumnsSet = new Set<string>();
layoutItems.forEach((item) => {
if (item.usedColumns) {
item.usedColumns.forEach((col) => layoutColumnsSet.add(col));
}
// bindField도 포함 (인풋 필드 등)
if (item.bindField) {
layoutColumnsSet.add(item.bindField);
}
});
const layoutColumnCount = layoutColumnsSet.size;
return {
tableCount: 1 + filterTables.length, // 메인 + 필터
fieldCount: layoutColumnCount > 0 ? layoutColumnCount : fieldMappings.length,
joinCount: totalJoins,
filterCount: totalFilters,
flowCount: dataFlows.length,
usedFields: layoutColumnsSet, // 사용 중인 컬럼 Set
};
}, [filterTables, fieldMappings, dataFlows, layoutItems]);
return (
<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">
<div className="flex items-center justify-between">
<h3 className="flex items-center gap-2 text-sm font-semibold">
<Database className="h-4 w-4 text-blue-500" />
</h3>
{mainTable && (
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() => onOpenTableSetting?.(mainTable, mainTableLabel)}
>
<Settings2 className="h-3 w-3" />
</Button>
)}
</div>
{mainTable ? (
<TableColumnAccordion
tableName={mainTable}
tableLabel={mainTableLabel}
tableType="main"
usedFields={stats.usedFields}
columnMappings={
// layoutItems에서 컬럼 매핑 정보 추출 (y 좌표 순서대로)
layoutItems
.slice()
.sort((a, b) => a.y - b.y) // 화면 순서대로 정렬
.flatMap((item, idx) => {
const cols: string[] = [];
// usedColumns에서 가져오기
if (item.usedColumns) {
cols.push(...item.usedColumns);
}
// bindField도 포함
if (item.bindField && !cols.includes(item.bindField)) {
cols.push(item.bindField);
}
return cols.map(col => ({
columnName: col,
fieldLabel: col,
order: idx * 100 + cols.indexOf(col),
}));
})
// 중복 제거 (첫 번째 매핑만 유지)
.filter((mapping, idx, arr) =>
arr.findIndex(m => m.columnName.toLowerCase() === mapping.columnName.toLowerCase()) === idx
)
}
onColumnChange={handleColumnChange}
onColumnReorder={(newOrder) => handleColumnReorder("main", newOrder)}
onJoinSettingSaved={onRefresh}
/>
) : (
<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;
confirmationEnabled?: boolean;
// 버튼 스타일
backgroundColor?: string;
textColor?: string;
// 모달/네비게이션 관련
modalScreenId?: number;
navigateScreenId?: number;
// 데이터 흐름 제어
hasDataflowControl?: boolean;
dataflowControlMode?: string;
flowTiming?: "before" | "after";
linkedExternalCall?: {
id: number;
name: string;
};
// 다중 플로우 지원
linkedFlows?: {
id: number;
name: string;
timing?: "before" | "after";
}[];
// 레거시 호환 (단일 플로우)
linkedFlow?: {
id: number;
name: string;
};
}
interface ControlManagementTabProps {
screenId: number;
groupId?: number;
layoutItems: LayoutItem[];
loading: boolean;
onRefresh: () => void;
}
function ControlManagementTab({
screenId,
groupId,
layoutItems,
loading: parentLoading,
onRefresh,
}: ControlManagementTabProps) {
const [buttonControls, setButtonControls] = useState<ButtonControlInfo[]>([]);
const [flows, setFlows] = useState<NodeFlow[]>([]);
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>>({});
// 화면 목록 조회 (inGroup: 같은 그룹 내 화면인지)
const [screenList, setScreenList] = useState<{ id: number; name: string; inGroup?: boolean }[]>([]);
// 화면 검색 팝오버 상태
const [openModalScreenSearch, setOpenModalScreenSearch] = useState<string | null>(null);
const [openNavigateScreenSearch, setOpenNavigateScreenSearch] = useState<string | null>(null);
const [openFlowSearch, setOpenFlowSearch] = useState<string | null>(null);
// 플로우 에디터 모달 상태 (전체 화면 임베드)
const [showFlowEditorModal, setShowFlowEditorModal] = useState(false);
const [flowEditorTargetButtonId, setFlowEditorTargetButtonId] = useState<string | null>(null);
// 플로우 빠른 생성 다이얼로그 상태 (골격 생성용 - 레거시)
const [showQuickFlowDialog, setShowQuickFlowDialog] = useState(false);
const [quickFlowData, setQuickFlowData] = useState({
name: "",
description: "",
tableName: "",
tableLabel: "",
actionType: "update" as "insert" | "update" | "delete",
autoLink: true,
targetButtonId: null as string | null,
});
const [isCreatingFlow, setIsCreatingFlow] = useState(false);
// 대기 중인 버튼 ID (새 창에서 플로우 생성 후 연동할 버튼)
const [pendingLinkButtonId, setPendingLinkButtonId] = useState<string | null>(null);
// postMessage 이벤트 리스너 (새 창에서 플로우 저장 완료 시)
useEffect(() => {
const handleMessage = async (event: MessageEvent) => {
if (event.data?.type === "FLOW_SAVED") {
const { flowId, flowName } = event.data;
// 플로우 목록 새로고침
const flowList = await getNodeFlows();
setFlows(flowList);
// 대기 중인 버튼에 연동
if (pendingLinkButtonId) {
const newFlow = {
id: flowId,
name: flowName,
timing: "after" as const,
};
setEditedValues(prev => ({
...prev,
[pendingLinkButtonId]: {
...prev[pendingLinkButtonId],
linkedFlows: [
...(prev[pendingLinkButtonId]?.linkedFlows ||
buttonControls.find(b => b.id === pendingLinkButtonId)?.linkedFlows || []),
newFlow,
],
},
}));
toast.success(`플로우 "${flowName}"이(가) 버튼에 연동되었습니다`);
setPendingLinkButtonId(null);
} else {
toast.success(`플로우 "${flowName}"이(가) 생성되었습니다`);
}
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [pendingLinkButtonId, buttonControls]);
// 제어 관리 페이지를 새 창으로 열기
const openFlowEditorInNewWindow = (buttonId?: string) => {
if (buttonId) {
setPendingLinkButtonId(buttonId);
}
window.open("/admin/systemMng/dataflow", "_blank", "width=1400,height=900");
};
// 데이터 로드
const loadData = useCallback(async () => {
setLoading(true);
try {
// 1. 화면 레이아웃에서 버튼 정보 추출
const layoutResponse = await screenApi.getLayout(screenId);
if (layoutResponse?.components) {
const buttons: ButtonControlInfo[] = [];
// 컴포넌트에서 버튼 추출 (화면 디자이너 구조 기준)
const extractButtons = (components: any[], depth = 0) => {
for (const comp of components) {
const config = comp.componentConfig || {};
// 버튼 컴포넌트 필터링 (화면 디자이너 저장 구조 기준)
// 1. 새 시스템: type="component" && widgetType="button"
// 2. 새 시스템: componentConfig.webType="button"
// 3. 레거시: type="button"
const isButton =
comp.widgetType === "button" ||
comp.webType === "button" ||
comp.type === "button" ||
config.webType === "button" ||
comp.componentType?.includes("button") ||
comp.componentKind?.includes("button");
if (isButton) {
const webTypeConfig = comp.webTypeConfig || {};
const action = config.action || {};
const style = comp.style || {};
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.confirmationMessage || action.confirmMessage || config.confirmMessage,
confirmationEnabled: action.confirmationEnabled ?? (!!action.confirmationMessage || !!action.confirmMessage),
// 버튼 스타일 (webTypeConfig 우선)
backgroundColor: webTypeConfig.backgroundColor || config.backgroundColor || style.backgroundColor,
textColor: webTypeConfig.textColor || config.textColor || style.color || style.labelColor,
// 모달/네비게이션 관련 (화면 디자이너는 targetScreenId 사용)
modalScreenId: action.targetScreenId || action.modalScreenId,
navigateScreenId: action.navigateScreenId || action.targetScreenId,
// 데이터 흐름 제어
hasDataflowControl: webTypeConfig.enableDataflowControl,
dataflowControlMode: webTypeConfig.dataflowConfig?.controlMode,
flowTiming: webTypeConfig.dataflowConfig?.flowConfig?.executionTiming,
linkedExternalCall: undefined, // TODO: 연결 정보 조회
// 다중 플로우 지원 (flowConfigs 배열 또는 단일 flowConfig)
linkedFlows: webTypeConfig.dataflowConfig?.flowConfigs?.map((fc: any) => ({
id: fc.flowId,
name: fc.flowName,
timing: fc.executionTiming || "after",
})) || (webTypeConfig.dataflowConfig?.flowConfig ? [{
id: webTypeConfig.dataflowConfig.flowConfig.flowId,
name: webTypeConfig.dataflowConfig.flowConfig.flowName,
timing: webTypeConfig.dataflowConfig.flowConfig.executionTiming || "after",
}] : []),
// 레거시 호환 (단일 플로우)
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);
setButtonControls(buttons);
}
// 2. 플로우 목록 조회 (버튼 연동용) - node_flows 테이블에서 가져옴
try {
const flowList = await getNodeFlows();
console.log("플로우 목록 응답:", flowList);
setFlows(flowList);
} catch (flowError) {
console.error("플로우 목록 조회 실패:", flowError);
}
// 3. 화면 목록 조회 (모달/네비게이션용)
// 먼저 전체 화면 목록 가져오기 (기존 연결된 화면이 다른 그룹에 있을 수 있음)
// 모든 화면 데이터를 가져오기 위해 최대 크기로 조회
const allScreensResponse = await screenApi.getScreens({ size: 1000 });
const allScreensMap = new Map<number, string>();
if (allScreensResponse.data && allScreensResponse.data.length > 0) {
allScreensResponse.data.forEach((s: any) => {
// ScreenDefinition 타입: screenId, screenName 필드 사용
const sid = Number(s.screenId || s.screen_id || s.id);
const sname = s.screenName || s.screen_name || s.name || `화면 ${sid}`;
if (!isNaN(sid)) {
allScreensMap.set(sid, sname);
}
});
}
// 그룹 내 화면 목록
let groupScreenIds: number[] = [];
if (groupId) {
const groupResponse = await getScreenGroup(groupId);
if (groupResponse.success && groupResponse.data?.screens) {
// API 응답 필드명: screen_id, screen_name (snake_case) - 문자열일 수 있으므로 Number()로 변환
groupScreenIds = groupResponse.data.screens.map((s: any) => Number(s.screen_id || s.screenId || s.id)).filter(id => !isNaN(id));
}
}
// 그룹 내 화면 우선, 전체 화면도 포함
const screenListResult: { id: number; name: string; inGroup: boolean }[] = [];
// 그룹 내 화면 먼저 추가 (숫자로 변환된 ID로 Map에서 조회)
groupScreenIds.forEach(sid => {
const name = allScreensMap.get(sid) || `화면 ${sid}`;
screenListResult.push({ id: sid, name, inGroup: true });
allScreensMap.delete(sid); // 중복 제거
});
// 나머지 전체 화면 추가 (다른 그룹에 있는 화면도 선택 가능하게)
allScreensMap.forEach((name, id) => {
screenListResult.push({ id, name, inGroup: false });
});
setScreenList(screenListResult);
} catch (error) {
console.error("제어 관리 데이터 로드 실패:", error);
toast.error("데이터 로드 실패");
} finally {
setLoading(false);
}
}, [screenId, groupId]);
useEffect(() => {
loadData();
}, [loadData]);
// 플로우 빠른 생성 함수
const handleQuickCreateFlow = async () => {
if (!quickFlowData.name.trim()) {
toast.error("플로우 이름을 입력해주세요");
return;
}
if (!quickFlowData.tableName) {
toast.error("테이블을 선택해주세요");
return;
}
setIsCreatingFlow(true);
try {
// 제어 플로우 에디터(FlowEditor.tsx onDrop)와 동일한 형식으로 flowData 생성
const timestamp = Date.now();
const sourceNodeId = `tableSource_${timestamp}`;
const actionNodeId = `${quickFlowData.actionType}Action_${timestamp}`;
// 액션 타입별 노드 타입 결정
let actionNodeType: string;
switch (quickFlowData.actionType) {
case "insert":
actionNodeType = "insertAction";
break;
case "update":
actionNodeType = "updateAction";
break;
case "delete":
actionNodeType = "deleteAction";
break;
default:
actionNodeType = "updateAction";
}
// 액션 노드 기본 데이터 (FlowEditor.tsx onDrop 패턴과 동일)
const actionNodeData: any = {
displayName: quickFlowData.actionType === "insert" ? "데이터 추가"
: quickFlowData.actionType === "update" ? "데이터 수정"
: "데이터 삭제",
// 🔥 FlowEditor.tsx와 동일한 기본값
targetType: "internal",
targetTable: quickFlowData.tableName,
targetTableLabel: quickFlowData.tableLabel || quickFlowData.tableName,
fieldMappings: [],
options: {},
};
// update/delete는 whereConditions 추가
if (quickFlowData.actionType === "update" || quickFlowData.actionType === "delete") {
actionNodeData.whereConditions = [];
}
// delete는 fieldMappings 제거 (삭제에는 필드 매핑 불필요)
if (quickFlowData.actionType === "delete") {
delete actionNodeData.fieldMappings;
}
const flowData = {
nodes: [
{
id: sourceNodeId,
type: "tableSource",
position: { x: 100, y: 150 },
data: {
// 🔥 FlowEditor.tsx와 동일한 기본값 (TableSourceProperties에서 테이블 선택 시 설정됨)
displayName: quickFlowData.tableLabel || quickFlowData.tableName || "테이블 소스",
tableName: quickFlowData.tableName,
fields: [],
// dataSourceType은 TableSourceProperties에서 기본값 "context-data" 사용
},
},
{
id: actionNodeId,
type: actionNodeType,
position: { x: 450, y: 150 },
data: actionNodeData,
},
],
edges: [
{
id: `edge_${timestamp}`,
source: sourceNodeId,
target: actionNodeId,
sourceHandle: null,
targetHandle: null,
},
],
};
// 플로우 생성 API 호출
const actionLabel = quickFlowData.actionType === "insert" ? "데이터 추가"
: quickFlowData.actionType === "update" ? "데이터 수정"
: "데이터 삭제";
const result = await createNodeFlow({
flowName: quickFlowData.name,
flowDescription: quickFlowData.description || `${quickFlowData.tableLabel || quickFlowData.tableName} ${actionLabel} 플로우`,
flowData: JSON.stringify(flowData),
});
toast.success(`플로우 "${quickFlowData.name}" 생성 완료`);
// 자동 연동 옵션이 켜져 있고 대상 버튼이 있으면 연동
if (quickFlowData.autoLink && quickFlowData.targetButtonId) {
const newFlow = {
id: result.flowId,
name: quickFlowData.name,
timing: "after" as const,
};
// 해당 버튼의 linkedFlows에 추가
setEditedValues(prev => ({
...prev,
[quickFlowData.targetButtonId!]: {
...prev[quickFlowData.targetButtonId!],
linkedFlows: [
...(prev[quickFlowData.targetButtonId!]?.linkedFlows ||
buttonControls.find(b => b.id === quickFlowData.targetButtonId)?.linkedFlows || []),
newFlow,
],
},
}));
toast.success(`버튼에 플로우 자동 연동 완료`);
}
// 플로우 목록 새로고침
const flowList = await getNodeFlows();
setFlows(flowList);
// 다이얼로그 닫기 및 상태 초기화
setShowQuickFlowDialog(false);
setQuickFlowData({
name: "",
description: "",
tableName: "",
tableLabel: "",
actionType: "update",
autoLink: true,
targetButtonId: null,
});
} catch (error) {
console.error("플로우 생성 실패:", error);
toast.error("플로우 생성 실패");
} finally {
setIsCreatingFlow(false);
}
};
// 버튼 설정 저장
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) {
const config = comp.componentConfig || {};
// 버튼 식별 조건 (화면 디자이너 저장 구조 기준)
const isButton =
comp.widgetType === "button" ||
comp.webType === "button" ||
comp.type === "button" ||
config.webType === "button" ||
comp.componentType?.includes("button") ||
comp.componentKind?.includes("button");
if ((comp.id === buttonId || comp.componentId === buttonId) && isButton) {
// componentConfig 업데이트
if (!comp.componentConfig) comp.componentConfig = {};
if (!comp.componentConfig.action) comp.componentConfig.action = {};
// 버튼 라벨(텍스트) 업데이트
if (values.label !== undefined) {
comp.componentConfig.text = values.label;
// 레거시 호환: 여러 위치에 저장
comp.label = values.label;
comp.title = values.label;
}
// 버튼 스타일(색상) 업데이트 (webTypeConfig에 저장해야 실제 버튼에 반영됨)
if (!comp.webTypeConfig) comp.webTypeConfig = {};
if (!comp.style) comp.style = {};
if (values.backgroundColor !== undefined) {
comp.webTypeConfig.backgroundColor = values.backgroundColor;
comp.componentConfig.backgroundColor = values.backgroundColor;
comp.style.backgroundColor = values.backgroundColor;
}
if (values.textColor !== undefined) {
comp.webTypeConfig.textColor = values.textColor;
comp.componentConfig.textColor = values.textColor;
comp.style.color = values.textColor;
comp.style.labelColor = values.textColor;
}
// 액션 타입 업데이트
if (values.actionType) {
comp.componentConfig.action.type = values.actionType;
}
// 대상 테이블 업데이트
if (values.targetTable !== undefined) {
comp.componentConfig.tableName = values.targetTable;
}
// 확인 다이얼로그 설정 (save/delete 액션에서만 유효)
const currentActionType = values.actionType || comp.componentConfig.action?.type;
if (currentActionType === "save" || currentActionType === "delete") {
if (values.confirmMessage !== undefined) {
comp.componentConfig.action.confirmMessage = values.confirmMessage;
}
} else {
// save/delete가 아닌 경우 confirmMessage 제거
if (comp.componentConfig.action) {
delete comp.componentConfig.action.confirmMessage;
}
}
// 모달/네비게이션 화면 설정 (화면 디자이너는 targetScreenId 사용)
if (values.modalScreenId !== undefined) {
comp.componentConfig.action.targetScreenId = values.modalScreenId || null;
}
if (values.navigateScreenId !== undefined) {
comp.componentConfig.action.targetScreenId = values.navigateScreenId || null;
}
if (values.operations) {
comp.componentConfig.action.operations = values.operations;
}
// webTypeConfig 업데이트 (플로우 연동 - 다중 플로우 지원)
if (!comp.webTypeConfig) comp.webTypeConfig = {};
// 다중 플로우 처리 (linkedFlows 배열)
if (values.linkedFlows !== undefined) {
if (values.linkedFlows && values.linkedFlows.length > 0) {
comp.webTypeConfig.enableDataflowControl = true;
comp.webTypeConfig.dataflowConfig = {
controlMode: "flow",
// 다중 플로우 저장
flowConfigs: values.linkedFlows.map((lf: any) => ({
flowId: lf.id,
flowName: lf.name,
executionTiming: lf.timing || "after",
})),
// 레거시 호환 - 첫 번째 플로우를 단일 flowConfig로도 저장
flowConfig: {
flowId: values.linkedFlows[0].id,
flowName: values.linkedFlows[0].name,
executionTiming: values.linkedFlows[0].timing || "after",
},
};
} else {
// 플로우 연동 해제 (빈 배열)
comp.webTypeConfig.enableDataflowControl = false;
delete comp.webTypeConfig.dataflowConfig;
}
}
// 레거시 단일 플로우 처리
else if (values.linkedFlowId) {
comp.webTypeConfig.enableDataflowControl = true;
comp.webTypeConfig.dataflowConfig = {
controlMode: "flow",
flowConfig: {
flowId: values.linkedFlowId,
flowName: flows.find(f => f.flowId === values.linkedFlowId)?.flowName || "",
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;
}
if (comp.componentConfig?.children && Array.isArray(comp.componentConfig.children)) {
if (updateButton(comp.componentConfig.children)) return true;
}
if (comp.items && Array.isArray(comp.items)) {
if (updateButton(comp.items)) 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: "삭제",
edit: "편집",
copy: "복사",
navigate: "페이지 이동",
modal: "모달 열기",
openModalWithData: "데이터+모달",
openRelatedModal: "연관모달",
transferData: "데이터전달",
quickInsert: "즉시저장",
control: "제어흐름",
view_table_history: "이력보기",
excel_download: "엑셀다운",
excel_upload: "엑셀업로드",
barcode_scan: "바코드스캔",
code_merge: "코드병합",
operation_control: "운행제어",
};
return labels[type] || type;
};
// 액션 타입 색상 (화면 디자이너와 동일) - hover 상태 포함
const getActionTypeColor = (type: string) => {
switch (type) {
case "save":
case "quickInsert":
return "bg-green-100 text-green-700 hover:bg-green-100 hover:text-green-700";
case "delete":
return "bg-red-100 text-red-700 hover:bg-red-100 hover:text-red-700";
case "edit":
case "copy":
return "bg-blue-100 text-blue-700 hover:bg-blue-100 hover:text-blue-700";
case "modal":
case "openModalWithData":
case "openRelatedModal":
return "bg-purple-100 text-purple-700 hover:bg-purple-100 hover:text-purple-700";
case "navigate":
return "bg-cyan-100 text-cyan-700 hover:bg-cyan-100 hover:text-cyan-700";
case "transferData":
case "control":
return "bg-amber-100 text-amber-700 hover:bg-amber-100 hover:text-amber-700";
case "excel_download":
case "excel_upload":
return "bg-emerald-100 text-emerald-700 hover:bg-emerald-100 hover:text-emerald-700";
case "view_table_history":
return "bg-slate-100 text-slate-700 hover:bg-slate-100 hover:text-slate-700";
default:
return "bg-gray-100 text-gray-700 hover:bg-gray-100 hover: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-6">
{/* 버튼 액션 설정 - 구분된 섹션 */}
<div className="rounded-lg border border-blue-200 bg-blue-50/30">
<div className="flex items-center gap-2 px-3 py-2 border-b border-blue-200 bg-blue-100/50 rounded-t-lg">
<MousePointer className="h-4 w-4 text-blue-600" />
<span className="text-sm font-semibold text-blue-900"> </span>
<Badge className="h-5 text-[10px] bg-blue-600 text-white hover:bg-blue-600">
{buttonControls.length}
</Badge>
</div>
<div className="max-h-[350px] overflow-y-auto p-2">
{buttonControls.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<MousePointer className="mb-2 h-6 w-6 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground"> </p>
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="divide-y">
{buttonControls.map((btn) => {
// 현재 편집 중인 값 또는 기본값
const currentLabel = editedValues[btn.id]?.label ?? btn.label;
const currentBgColor = editedValues[btn.id]?.backgroundColor ?? btn.backgroundColor ?? "#3b82f6";
const currentTextColor = editedValues[btn.id]?.textColor ?? btn.textColor ?? "#ffffff";
return (
<div key={btn.id} className="py-3 px-1">
{/* 버튼 헤더: 프리뷰 + 이름 입력 + 저장 버튼 */}
<div className="flex items-center gap-3 mb-3">
{/* 버튼 프리뷰 */}
<div
className="flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium min-w-[60px] shrink-0"
style={{
backgroundColor: currentBgColor,
color: currentTextColor,
}}
>
{currentLabel || "버튼"}
</div>
{/* 버튼 이름 입력 */}
<div className="flex-1">
<Input
value={currentLabel}
onChange={(e) => setEditedValues(prev => ({
...prev,
[btn.id]: { ...prev[btn.id], label: e.target.value }
}))}
className="h-7 text-sm"
placeholder="버튼 이름"
/>
</div>
{btn.hasDataflowControl && (
<Badge variant="outline" className="h-5 text-[10px] border-purple-300 text-purple-600 shrink-0">
<Zap className="mr-1 h-2.5 w-2.5" />
</Badge>
)}
<Button
size="sm"
className="h-7 text-xs px-3 shrink-0"
onClick={() => handleSaveButton(btn.id)}
disabled={!editedValues[btn.id]}
>
<Save className="mr-1 h-3 w-3" />
</Button>
</div>
{/* 버튼 설정 (상시 편집) */}
<div className="space-y-2">
{/* 액션 타입 */}
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
<Label className="text-xs text-muted-foreground"> </Label>
<Select
value={editedValues[btn.id]?.actionType || btn.actionType || "save"}
onValueChange={(val) => setEditedValues(prev => ({
...prev,
[btn.id]: { ...prev[btn.id], actionType: val }
}))}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="액션 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="save" className="text-xs"></SelectItem>
<SelectItem value="delete" className="text-xs"></SelectItem>
<SelectItem value="edit" className="text-xs"></SelectItem>
<SelectItem value="copy" className="text-xs"></SelectItem>
<SelectItem value="navigate" className="text-xs"> </SelectItem>
<SelectItem value="modal" className="text-xs"> </SelectItem>
<SelectItem value="openModalWithData" className="text-xs"> + </SelectItem>
<SelectItem value="openRelatedModal" className="text-xs"> </SelectItem>
<SelectItem value="transferData" className="text-xs"> </SelectItem>
<SelectItem value="quickInsert" className="text-xs"> </SelectItem>
<SelectItem value="control" className="text-xs"> </SelectItem>
<SelectItem value="view_table_history" className="text-xs"> </SelectItem>
<SelectItem value="excel_download" className="text-xs"> </SelectItem>
<SelectItem value="excel_upload" className="text-xs"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 버튼 스타일 (배경색 + 글자색) */}
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
<Label className="text-xs text-muted-foreground"> </Label>
<div className="flex items-center gap-2">
{/* 배경색 */}
<div className="flex items-center gap-1">
<span className="text-[10px] text-muted-foreground"></span>
<input
type="color"
value={currentBgColor}
onChange={(e) => setEditedValues(prev => ({
...prev,
[btn.id]: { ...prev[btn.id], backgroundColor: e.target.value }
}))}
className="h-6 w-8 rounded border cursor-pointer"
/>
</div>
{/* 글자색 */}
<div className="flex items-center gap-1">
<span className="text-[10px] text-muted-foreground"></span>
<input
type="color"
value={currentTextColor}
onChange={(e) => setEditedValues(prev => ({
...prev,
[btn.id]: { ...prev[btn.id], textColor: e.target.value }
}))}
className="h-6 w-8 rounded border cursor-pointer"
/>
</div>
{/* 프리셋 색상 */}
<div className="flex items-center gap-1 ml-2">
{[
{ bg: "#3b82f6", text: "#ffffff", name: "파랑" },
{ bg: "#22c55e", text: "#ffffff", name: "초록" },
{ bg: "#ef4444", text: "#ffffff", name: "빨강" },
{ bg: "#6b7280", text: "#ffffff", name: "회색" },
{ bg: "#ffffff", text: "#374151", name: "흰색" },
].map((preset) => (
<button
key={preset.name}
type="button"
onClick={() => setEditedValues(prev => ({
...prev,
[btn.id]: { ...prev[btn.id], backgroundColor: preset.bg, textColor: preset.text }
}))}
className="h-5 w-5 rounded border border-gray-300 hover:ring-2 hover:ring-blue-300 transition-all"
style={{ backgroundColor: preset.bg }}
title={preset.name}
/>
))}
</div>
</div>
</div>
{/* 확인 메시지 설정 (save/delete 액션에서만 표시) */}
{((editedValues[btn.id]?.actionType || btn.actionType) === "save" ||
(editedValues[btn.id]?.actionType || btn.actionType) === "delete") && (
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
<Label className="text-xs text-muted-foreground"> </Label>
<Input
value={editedValues[btn.id]?.confirmMessage ?? btn.confirmMessage ?? ""}
onChange={(e) => setEditedValues(prev => ({
...prev,
[btn.id]: { ...prev[btn.id], confirmMessage: e.target.value }
}))}
placeholder="커스텀 메시지 (예: 정말 삭제하시겠습니까?)"
className="h-7 text-xs"
/>
</div>
)}
{/* 모달 화면 선택 (modal, openModalWithData, openRelatedModal 액션) */}
{((editedValues[btn.id]?.actionType || btn.actionType) === "modal" ||
(editedValues[btn.id]?.actionType || btn.actionType) === "openModalWithData" ||
(editedValues[btn.id]?.actionType || btn.actionType) === "openRelatedModal") && (
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
<Label className="text-xs text-muted-foreground"> </Label>
<Popover open={openModalScreenSearch === btn.id} onOpenChange={(open) => setOpenModalScreenSearch(open ? btn.id : null)}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
{editedValues[btn.id]?.modalScreenId || btn.modalScreenId
? screenList.find(s => s.id === Number(editedValues[btn.id]?.modalScreenId || btn.modalScreenId))?.name || "화면 선택"
: "화면 선택"
}
<ChevronsUpDown className="ml-2 h-3 w-3 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="text-xs py-2 text-center"> </CommandEmpty>
<CommandItem
value="__none__"
onSelect={() => {
setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], modalScreenId: null } }));
setOpenModalScreenSearch(null);
}}
className="text-xs text-muted-foreground"
>
<Check className={cn("mr-2 h-3 w-3", !(editedValues[btn.id]?.modalScreenId || btn.modalScreenId) ? "opacity-100" : "opacity-0")} />
</CommandItem>
{screenList.filter(s => s && s.id != null && s.inGroup).length > 0 && (
<CommandGroup heading="현재 그룹">
{screenList.filter(s => s && s.id != null && s.inGroup).map((s) => (
<CommandItem
key={s.id}
value={s.name}
onSelect={() => {
setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], modalScreenId: s.id } }));
setOpenModalScreenSearch(null);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", Number(editedValues[btn.id]?.modalScreenId || btn.modalScreenId) === s.id ? "opacity-100" : "opacity-0")} />
{s.name}
</CommandItem>
))}
</CommandGroup>
)}
{screenList.filter(s => s && s.id != null && !s.inGroup).length > 0 && (
<CommandGroup heading="다른 그룹">
{screenList.filter(s => s && s.id != null && !s.inGroup).map((s) => (
<CommandItem
key={s.id}
value={s.name}
onSelect={() => {
setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], modalScreenId: s.id } }));
setOpenModalScreenSearch(null);
}}
className="text-xs text-muted-foreground"
>
<Check className={cn("mr-2 h-3 w-3", Number(editedValues[btn.id]?.modalScreenId || btn.modalScreenId) === s.id ? "opacity-100" : "opacity-0")} />
{s.name}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* 네비게이션 화면 선택 (navigate 액션) */}
{(editedValues[btn.id]?.actionType || btn.actionType) === "navigate" && (
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
<Label className="text-xs text-muted-foreground"> </Label>
<Popover open={openNavigateScreenSearch === btn.id} onOpenChange={(open) => setOpenNavigateScreenSearch(open ? btn.id : null)}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
{editedValues[btn.id]?.navigateScreenId || btn.navigateScreenId
? screenList.find(s => s.id === Number(editedValues[btn.id]?.navigateScreenId || btn.navigateScreenId))?.name || "화면 선택"
: "화면 선택"
}
<ChevronsUpDown className="ml-2 h-3 w-3 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="text-xs py-2 text-center"> </CommandEmpty>
<CommandItem
value="__none__"
onSelect={() => {
setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], navigateScreenId: null } }));
setOpenNavigateScreenSearch(null);
}}
className="text-xs text-muted-foreground"
>
<Check className={cn("mr-2 h-3 w-3", !(editedValues[btn.id]?.navigateScreenId || btn.navigateScreenId) ? "opacity-100" : "opacity-0")} />
</CommandItem>
{screenList.filter(s => s && s.id != null && s.inGroup).length > 0 && (
<CommandGroup heading="현재 그룹">
{screenList.filter(s => s && s.id != null && s.inGroup).map((s) => (
<CommandItem
key={s.id}
value={s.name}
onSelect={() => {
setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], navigateScreenId: s.id } }));
setOpenNavigateScreenSearch(null);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", Number(editedValues[btn.id]?.navigateScreenId || btn.navigateScreenId) === s.id ? "opacity-100" : "opacity-0")} />
{s.name}
</CommandItem>
))}
</CommandGroup>
)}
{screenList.filter(s => s && s.id != null && !s.inGroup).length > 0 && (
<CommandGroup heading="다른 그룹">
{screenList.filter(s => s && s.id != null && !s.inGroup).map((s) => (
<CommandItem
key={s.id}
value={s.name}
onSelect={() => {
setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], navigateScreenId: s.id } }));
setOpenNavigateScreenSearch(null);
}}
className="text-xs text-muted-foreground"
>
<Check className={cn("mr-2 h-3 w-3", Number(editedValues[btn.id]?.navigateScreenId || btn.navigateScreenId) === s.id ? "opacity-100" : "opacity-0")} />
{s.name}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* 플로우 연동 - 세로 목록 형식 */}
<div className="grid grid-cols-[80px_1fr] items-start gap-2">
<Label className="text-xs text-muted-foreground pt-1"></Label>
<div className="space-y-2">
{/* 연동된 플로우 목록 (세로 형식, 각각 타이밍 선택) */}
{(() => {
const currentFlows = editedValues[btn.id]?.linkedFlows || btn.linkedFlows || [];
return currentFlows.length > 0 ? (
<div className="space-y-1.5">
{currentFlows.map((lf: { id: number; name: string; timing?: string }, idx: number) => (
<div key={lf.id} className="flex items-center gap-2 p-1.5 border rounded-md bg-muted/30">
<Workflow className="h-3 w-3 text-purple-500 shrink-0" />
<span className="text-xs font-medium text-gray-700 flex-1 truncate">
{lf.name}
</span>
{/* 타이밍 선택 */}
<Select
value={lf.timing || "after"}
onValueChange={(val) => {
const newFlows = [...currentFlows];
newFlows[idx] = { ...lf, timing: val };
setEditedValues(prev => ({
...prev,
[btn.id]: { ...prev[btn.id], linkedFlows: newFlows }
}));
}}
>
<SelectTrigger className="h-6 w-[70px] text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="before" className="text-[10px]"> </SelectItem>
<SelectItem value="after" className="text-[10px]"> </SelectItem>
</SelectContent>
</Select>
{/* 삭제 버튼 */}
<button
type="button"
className="h-5 w-5 rounded hover:bg-red-100 flex items-center justify-center text-gray-400 hover:text-red-500"
onClick={() => {
const newFlows = currentFlows.filter((f: any) => f.id !== lf.id);
setEditedValues(prev => ({
...prev,
[btn.id]: { ...prev[btn.id], linkedFlows: newFlows }
}));
}}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
) : null;
})()}
{/* 플로우 추가 버튼 */}
<Popover open={openFlowSearch === btn.id} onOpenChange={(open) => setOpenFlowSearch(open ? btn.id : null)}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
<span className="flex items-center gap-1">
<Plus className="h-3 w-3" />
</span>
<ChevronsUpDown className="ml-2 h-3 w-3 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="text-xs py-2 text-center"> </CommandEmpty>
{/* 빠른 생성 옵션 */}
<CommandGroup>
<CommandItem
onSelect={() => {
setOpenFlowSearch(null);
// FlowEditor 모달 열기 (버튼 연동)
setFlowEditorTargetButtonId(btn.id);
setShowFlowEditorModal(true);
setOpenFlowSearch(null);
}}
className="text-xs text-purple-700 bg-purple-50 hover:bg-purple-100"
>
<Plus className="mr-2 h-3 w-3 text-purple-500" />
<div className="flex flex-col">
<span className="font-medium"> </span>
<span className="text-[10px] text-purple-500"> </span>
</div>
</CommandItem>
</CommandGroup>
{flows.length > 0 ? (
<CommandGroup heading="플로우 목록">
{flows.map((f) => {
const currentFlows = editedValues[btn.id]?.linkedFlows || btn.linkedFlows || [];
const isLinked = currentFlows.some((lf: any) => lf.id === f.flowId);
const tableName = typeof f.flowData === 'object'
? f.flowData?.nodes?.find((n: any) => n.type === 'tableSource')?.data?.tableName
: null;
return (
<CommandItem
key={f.flowId}
value={f.flowName}
onSelect={() => {
if (!isLinked) {
const newFlows = [...currentFlows, { id: f.flowId, name: f.flowName, timing: "after" }];
setEditedValues(prev => ({
...prev,
[btn.id]: { ...prev[btn.id], linkedFlows: newFlows }
}));
}
setOpenFlowSearch(null);
}}
className={cn("text-xs", isLinked && "opacity-50")}
disabled={isLinked}
>
<Check className={cn("mr-2 h-3 w-3", isLinked ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span>{f.flowName}</span>
{tableName && <span className="text-[10px] text-muted-foreground">{tableName}</span>}
</div>
{isLinked && <span className="ml-auto text-[10px] text-muted-foreground"></span>}
</CommandItem>
);
})}
</CommandGroup>
) : (
<div className="py-2 px-2 text-xs text-muted-foreground text-center">
</div>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* 플로우 연동 - 구분된 섹션 */}
<div className="rounded-lg border border-purple-200 bg-purple-50/30">
<div className="flex items-center gap-2 px-3 py-2 border-b border-purple-200 bg-purple-100/50 rounded-t-lg">
<Workflow className="h-4 w-4 text-purple-600" />
<span className="text-sm font-semibold text-purple-900"> </span>
<Badge className="h-5 text-[10px] bg-purple-600 text-white hover:bg-purple-600">
{flows.length}
</Badge>
<Badge className="h-5 text-[10px] bg-green-600 text-white hover:bg-green-600">
{buttonControls.filter(b => (b.linkedFlows && b.linkedFlows.length > 0) || b.linkedFlow).length}
</Badge>
<span className="flex-1" />
<Button
variant="outline"
size="sm"
className="h-5 px-1.5 text-[10px] border-purple-300 text-purple-700 hover:text-purple-900 hover:bg-purple-100"
onClick={() => {
// FlowEditor 모달 열기 (버튼 연동 없이)
setFlowEditorTargetButtonId(null);
setShowFlowEditorModal(true);
}}
title="플로우 생성"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-[10px] text-purple-700 hover:text-purple-900 hover:bg-purple-200"
onClick={() => window.open("/admin/systemMng/dataflow", "_blank")}
title="플로우 관리"
>
<ExternalLink className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="max-h-[180px] overflow-y-auto p-2">
{flows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-4 text-center">
<Workflow className="mb-1 h-5 w-5 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-1">
{flows.map((flow) => {
// 이 플로우가 연동된 버튼들 찾기 (다중 플로우 지원)
const linkedButtons = buttonControls.filter(b =>
(b.linkedFlows && b.linkedFlows.some(lf => lf.id === flow.flowId)) ||
b.linkedFlow?.id === flow.flowId
);
const tableName = (flow as any).tableType || (flow as any).tableName;
return (
<div key={`flow-list-${flow.flowId}`} className="flex items-center gap-2 px-2 py-1.5 rounded bg-white/50 hover:bg-white">
{/* 플로우 이름 - 일반 텍스트 */}
<Workflow className="h-3.5 w-3.5 text-purple-500 shrink-0" />
<span className="text-xs font-medium text-gray-700 shrink-0">
{flow.flowName}
</span>
{tableName && (
<span className="text-[10px] text-muted-foreground shrink-0">
({tableName})
</span>
)}
<span className="flex-1" />
{linkedButtons.length > 0 ? (
<div className="flex items-center gap-1 shrink-0 flex-wrap justify-end">
{linkedButtons.map((btn, idx) => {
// 해당 버튼에서 이 플로우의 타이밍 정보 추출
const flowInfo = btn.linkedFlows?.find(lf => lf.id === flow.flowId);
const timing = flowInfo?.timing || btn.flowTiming || "after";
return (
<div key={`${flow.flowId}-${btn.id}`} className="flex items-center gap-1">
{idx === 0 && <ArrowRight className="h-3 w-3 text-green-500" />}
<span className="text-xs text-green-600 font-medium">
[{btn.label}]
</span>
<Badge className={cn("h-4 text-[9px]", getActionTypeColor(btn.actionType))}>
{getActionTypeLabel(btn.actionType)}
</Badge>
<span className="text-[10px] text-muted-foreground">
({timing === "before" ? "전" : "후"})
</span>
</div>
);
})}
</div>
) : (
/* 미연동 - 보라색 뱃지 */
<Badge variant="outline" className="h-5 text-[10px] border-purple-300 bg-purple-100 text-purple-600 shrink-0">
</Badge>
)}
</div>
);
})}
</div>
)}
</div>
</div>
{/* 플로우 빠른 생성 다이얼로그 */}
<Dialog open={showQuickFlowDialog} onOpenChange={setShowQuickFlowDialog}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
<strong> </strong> .
</DialogDescription>
</DialogHeader>
{/* 중요 안내 */}
<div className="p-2 rounded-md bg-amber-50 border border-amber-200 text-xs text-amber-800">
<div className="flex items-start gap-2">
<span className="text-amber-500 mt-0.5">&#9888;</span>
<div>
<p className="font-medium"> </p>
<p className="mt-1 text-amber-700">
, WHERE .
</p>
</div>
</div>
</div>
<div className="space-y-4">
{/* 플로우 이름 */}
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Input
value={quickFlowData.name}
onChange={(e) => setQuickFlowData(prev => ({ ...prev, name: e.target.value }))}
placeholder="예: 고객정보 수정 플로우"
className="h-8 text-xs sm:h-10 sm:text-sm mt-1"
/>
</div>
{/* 테이블 선택/입력 */}
<div>
<Label className="text-xs sm:text-sm"> *</Label>
{(() => {
const availableTables = Array.from(new Set(buttonControls.filter(b => b.targetTable).map(b => b.targetTable)));
return availableTables.length > 0 ? (
<>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm mt-1"
>
{quickFlowData.tableName
? (quickFlowData.tableLabel || quickFlowData.tableName)
: "테이블 선택..."}
<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="text-xs py-2 text-center"> </CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table}
value={table}
onSelect={() => {
setQuickFlowData(prev => ({
...prev,
tableName: table || "",
tableLabel: table || "",
}));
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", quickFlowData.tableName === table ? "opacity-100" : "opacity-0")} />
<Database className="mr-2 h-3 w-3 text-blue-500" />
{table}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-[10px] text-muted-foreground mt-1"> </p>
</>
) : (
<>
<Input
value={quickFlowData.tableName}
onChange={(e) => setQuickFlowData(prev => ({
...prev,
tableName: e.target.value,
tableLabel: e.target.value,
}))}
placeholder="테이블명 입력 (예: customer_mng)"
className="h-8 text-xs sm:h-10 sm:text-sm mt-1"
/>
<p className="text-[10px] text-muted-foreground mt-1"> </p>
</>
);
})()}
</div>
{/* 액션 타입 */}
<div>
<Label className="text-xs sm:text-sm"> </Label>
<div className="flex gap-2 mt-2">
{[
{ value: "insert", label: "INSERT", color: "bg-green-100 text-green-700 border-green-300" },
{ value: "update", label: "UPDATE", color: "bg-blue-100 text-blue-700 border-blue-300" },
{ value: "delete", label: "DELETE", color: "bg-red-100 text-red-700 border-red-300" },
].map((action) => (
<button
key={action.value}
type="button"
onClick={() => setQuickFlowData(prev => ({ ...prev, actionType: action.value as any }))}
className={cn(
"flex-1 py-2 px-3 text-xs font-medium rounded-md border transition-all",
quickFlowData.actionType === action.value
? action.color + " ring-2 ring-offset-1"
: "bg-gray-50 text-gray-600 border-gray-200 hover:bg-gray-100"
)}
>
{action.label}
</button>
))}
</div>
</div>
{/* 설명 (선택) */}
<div>
<Label className="text-xs sm:text-sm"> ()</Label>
<Textarea
value={quickFlowData.description}
onChange={(e) => setQuickFlowData(prev => ({ ...prev, description: e.target.value }))}
placeholder="플로우에 대한 설명을 입력하세요"
className="h-16 text-xs sm:text-sm mt-1 resize-none"
/>
</div>
{/* 자동 연동 옵션 */}
{quickFlowData.targetButtonId && (
<div className="flex items-center gap-2 p-2 rounded-md bg-purple-50 border border-purple-200">
<input
type="checkbox"
id="autoLink"
checked={quickFlowData.autoLink}
onChange={(e) => setQuickFlowData(prev => ({ ...prev, autoLink: e.target.checked }))}
className="h-4 w-4 rounded border-gray-300"
/>
<label htmlFor="autoLink" className="text-xs text-purple-700">
[{buttonControls.find(b => b.id === quickFlowData.targetButtonId)?.label}]
</label>
</div>
)}
</div>
{/* 버튼 영역 */}
<div className="flex flex-col gap-2 pt-2 border-t">
{/* 제어 관리에서 직접 생성 (권장) */}
<Button
variant="default"
onClick={() => {
if (quickFlowData.targetButtonId) {
setPendingLinkButtonId(quickFlowData.targetButtonId);
}
setShowQuickFlowDialog(false);
openFlowEditorInNewWindow(quickFlowData.targetButtonId || undefined);
}}
className="h-10 w-full text-sm font-medium"
>
<ExternalLink className="mr-2 h-4 w-4" />
()
</Button>
<p className="text-[10px] text-center text-muted-foreground -mt-1">
, WHERE . .
</p>
{/* 구분선 */}
<div className="flex items-center gap-2 my-1">
<div className="flex-1 h-px bg-border" />
<span className="text-[10px] text-muted-foreground"></span>
<div className="flex-1 h-px bg-border" />
</div>
{/* 하단 버튼들 */}
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setShowQuickFlowDialog(false)}
className="h-8 text-xs"
>
</Button>
<Button
variant="outline"
onClick={handleQuickCreateFlow}
disabled={isCreatingFlow || !quickFlowData.name.trim() || !quickFlowData.tableName}
className="h-8 text-xs"
>
{isCreatingFlow ? (
<>
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
...
</>
) : (
"골격만 생성"
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* FlowEditor 전체 화면 모달 */}
<Dialog open={showFlowEditorModal} onOpenChange={setShowFlowEditorModal}>
<DialogContent className="max-w-[98vw] h-[95vh] p-0 overflow-hidden">
<div className="flex flex-col h-full">
{/* 헤더 */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-background">
<div>
<h2 className="text-lg font-semibold"> </h2>
<p className="text-xs text-muted-foreground">
{flowEditorTargetButtonId && (
<span className="ml-1 text-purple-600">
(: {buttonControls.find(b => b.id === flowEditorTargetButtonId)?.label})
</span>
)}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowFlowEditorModal(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
{/* FlowEditor */}
<div className="flex-1 overflow-hidden">
<FlowEditor
embedded={true}
onSaveComplete={(flowId, flowName) => {
// 플로우 목록 새로고침
getNodeFlows().then(setFlows);
// 대상 버튼에 연동
if (flowEditorTargetButtonId) {
const newFlow = {
id: flowId,
name: flowName,
timing: "after" as const,
};
setEditedValues(prev => ({
...prev,
[flowEditorTargetButtonId]: {
...prev[flowEditorTargetButtonId],
linkedFlows: [
...(prev[flowEditorTargetButtonId]?.linkedFlows ||
buttonControls.find(b => b.id === flowEditorTargetButtonId)?.linkedFlows || []),
newFlow,
],
},
}));
toast.success(`플로우 "${flowName}"이(가) 버튼에 연동되었습니다`);
} else {
toast.success(`플로우 "${flowName}"이(가) 생성되었습니다`);
}
// 모달 닫기
setShowFlowEditorModal(false);
setFlowEditorTargetButtonId(null);
}}
/>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
// ============================================================
// 탭 4: 화면 프리뷰 (iframe)
// ============================================================
interface PreviewTabProps {
screenId: number;
screenName: string;
companyCode?: string;
iframeKey?: number; // iframe 새로고침용 키
canvasWidth?: number; // 화면 캔버스 너비
canvasHeight?: number; // 화면 캔버스 높이
}
function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight }: PreviewTabProps) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// 화면 디자인 크기 (실제 캔버스 크기 사용, 없으면 기본값)
// 좌우/상하 여유 마진 추가: 모달 패딩, 헤더, 하단 체크박스 등
// 좌우: +120px (양쪽 패딩 + 여유), 상하: +250px (헤더 + 버튼 + 체크박스 + 패딩)
const designWidth = Math.max((canvasWidth || 400) + 120, 500);
const designHeight = Math.max((canvasHeight || 400) + 250, 650);
// 컨테이너에 맞는 초기 스케일 계산
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;