- Added new entries to .gitignore for multi-agent MCP task queue and related rules. - Removed "즉시 저장" (quick insert) options from the ScreenSettingModal and BasicTab components to streamline button configurations. - Cleaned up unused event options in the V2ButtonConfigPanel to enhance clarity and maintainability. These changes aim to improve project organization and simplify the user interface by eliminating redundant options.
5061 lines
221 KiB
TypeScript
5061 lines
221 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
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 [loading, setLoading] = useState(false);
|
||
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
|
||
const [layoutItems, setLayoutItems] = useState<LayoutItem[]>([]);
|
||
const [buttonControls, setButtonControls] = useState<ButtonControlInfo[]>([]);
|
||
const [iframeKey, setIframeKey] = useState(0);
|
||
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,
|
||
});
|
||
}
|
||
|
||
// 3. 버튼 정보 추출 (읽기 전용 요약용)
|
||
try {
|
||
const rawLayout = await screenApi.getLayout(currentScreenId);
|
||
if (rawLayout?.components) {
|
||
const buttons: ButtonControlInfo[] = [];
|
||
const extractButtons = (components: any[]) => {
|
||
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 (isButton) {
|
||
const webTypeConfig = comp.webTypeConfig || {};
|
||
const action = config.action || {};
|
||
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"),
|
||
confirmMessage: action.confirmationMessage || action.confirmMessage || config.confirmMessage,
|
||
confirmationEnabled: action.confirmationEnabled ?? (!!action.confirmationMessage || !!action.confirmMessage),
|
||
backgroundColor: webTypeConfig.backgroundColor || config.backgroundColor || comp.style?.backgroundColor,
|
||
textColor: webTypeConfig.textColor || config.textColor || comp.style?.color,
|
||
borderRadius: webTypeConfig.borderRadius || config.borderRadius || comp.style?.borderRadius,
|
||
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",
|
||
}] : []),
|
||
});
|
||
}
|
||
if (comp.children && Array.isArray(comp.children)) extractButtons(comp.children);
|
||
if (comp.componentConfig?.children && Array.isArray(comp.componentConfig.children)) extractButtons(comp.componentConfig.children);
|
||
if (comp.items && Array.isArray(comp.items)) extractButtons(comp.items);
|
||
}
|
||
};
|
||
extractButtons(rawLayout.components);
|
||
setButtonControls(buttons);
|
||
}
|
||
} catch (btnError) {
|
||
console.error("버튼 정보 추출 실패:", btnError);
|
||
}
|
||
} 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);
|
||
}, [loadData]);
|
||
|
||
// 통계 계산
|
||
const stats = useMemo(() => {
|
||
const totalJoins = filterTables.reduce((sum, ft) => sum + (ft.joinColumnRefs?.length || 0), 0);
|
||
const layoutColumnsSet = new Set<string>();
|
||
layoutItems.forEach((item) => {
|
||
if (item.usedColumns) item.usedColumns.forEach((col) => layoutColumnsSet.add(col));
|
||
if (item.bindField) layoutColumnsSet.add(item.bindField);
|
||
});
|
||
const inputCount = layoutItems.filter(i => !i.widgetType?.includes("button") && !i.componentKind?.includes("table")).length;
|
||
const gridCount = layoutItems.filter(i => i.componentKind?.includes("table") || i.componentKind?.includes("grid")).length;
|
||
return {
|
||
tableCount: 1 + filterTables.length,
|
||
fieldCount: layoutColumnsSet.size || fieldMappings.length,
|
||
joinCount: totalJoins,
|
||
flowCount: dataFlows.length,
|
||
inputCount,
|
||
gridCount,
|
||
buttonCount: buttonControls.length,
|
||
};
|
||
}, [filterTables, fieldMappings, dataFlows, layoutItems, buttonControls]);
|
||
|
||
// 연결된 플로우 총 개수
|
||
const linkedFlowCount = useMemo(() => {
|
||
return buttonControls.reduce((sum, btn) => sum + (btn.linkedFlows?.length || 0), 0);
|
||
}, [buttonControls]);
|
||
|
||
return (
|
||
<>
|
||
<Dialog open={isOpen && !showDesignerModal} onOpenChange={onClose}>
|
||
<DialogContent className="flex h-[90vh] max-h-[950px] w-[98vw] max-w-[1600px] flex-col overflow-hidden border-border/40 bg-background/95 backdrop-blur-xl">
|
||
{/* V3 Header */}
|
||
<DialogHeader className="flex-shrink-0 pb-0">
|
||
<DialogTitle className="flex items-center gap-3 text-base">
|
||
<span className="h-2 w-2 rounded-full bg-emerald-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]" />
|
||
<span className="font-bold tracking-tight">{currentScreenName}</span>
|
||
{groupScreens.length > 1 && (
|
||
<>
|
||
<span className="h-3.5 w-px bg-border" />
|
||
<Select
|
||
value={currentScreenId.toString()}
|
||
onValueChange={(value) => handleScreenChange(parseInt(value, 10))}
|
||
>
|
||
<SelectTrigger className="h-7 w-auto min-w-[140px] max-w-[280px] border-border/40 bg-muted/30 text-xs font-medium">
|
||
<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-1 text-muted-foreground">({screen.screen_role})</span>
|
||
)}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</>
|
||
)}
|
||
<span className="ml-auto font-mono text-[10px] text-muted-foreground/60">#{currentScreenId}</span>
|
||
<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>
|
||
</DialogTitle>
|
||
<DialogDescription className="sr-only">화면 정보 패널</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{/* V3 Body: Left Info Panel + Right Preview */}
|
||
<div className="flex min-h-0 flex-1 gap-3 pt-2">
|
||
{/* 왼쪽: 정보 패널 (탭 없음, 단일 스크롤) */}
|
||
<div className="flex min-h-0 w-[380px] flex-shrink-0 flex-col rounded-lg border border-border/40">
|
||
<div className="flex-1 overflow-y-auto p-4 [&::-webkit-scrollbar]:w-[2px] [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/50">
|
||
|
||
{/* 1. 내러티브 요약 */}
|
||
<div className="mb-4 rounded-lg border border-primary/10 bg-gradient-to-br from-primary/[0.04] to-blue-500/[0.02] p-3">
|
||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||
<span className="font-semibold text-foreground">{currentMainTable || "테이블 미연결"}</span>
|
||
{stats.fieldCount > 0 && <> 테이블의 <span className="font-bold text-primary">{stats.fieldCount}개</span> 컬럼을 사용하고 있어요.</>}
|
||
{filterTables.length > 0 && <><br />필터 테이블 {filterTables.length}개{stats.joinCount > 0 && <>, 엔티티 조인 {stats.joinCount}개</>}가 연결되어 있어요.</>}
|
||
</p>
|
||
</div>
|
||
|
||
{/* 2. 속성 테이블 */}
|
||
<div className="mb-4 space-y-0.5">
|
||
<div className="flex h-[30px] items-center rounded-md px-2 text-[11px] transition-colors hover:bg-muted/30">
|
||
<span className="w-[76px] flex-shrink-0 text-[10px] font-medium text-muted-foreground">메인 테이블</span>
|
||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] font-semibold">{currentMainTable || "-"}</span>
|
||
{stats.fieldCount > 0 && <span className="flex-shrink-0 font-mono text-[9px] text-muted-foreground">{stats.fieldCount} 컬럼</span>}
|
||
</div>
|
||
{filterTables.map((ft, idx) => (
|
||
<div key={idx} className="flex h-[30px] items-center rounded-md px-2 text-[11px] transition-colors hover:bg-muted/30">
|
||
<span className="w-[76px] flex-shrink-0 text-[10px] font-medium text-muted-foreground">필터 테이블</span>
|
||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] font-semibold text-emerald-500">{ft.tableName}</span>
|
||
<span className="flex-shrink-0 font-mono text-[9px] text-muted-foreground">FK</span>
|
||
</div>
|
||
))}
|
||
{filterTables.some(ft => ft.joinColumnRefs && ft.joinColumnRefs.length > 0) && (
|
||
<div className="flex h-[30px] items-center rounded-md px-2 text-[11px] transition-colors hover:bg-muted/30">
|
||
<span className="w-[76px] flex-shrink-0 text-[10px] font-medium text-muted-foreground">엔티티 조인</span>
|
||
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold">
|
||
{filterTables.flatMap(ft => ft.joinColumnRefs || []).map((j, i) => (
|
||
<span key={i}>{i > 0 && ", "}<span className="font-mono">{j.column}</span> → <span className="font-mono text-amber-500">{j.refTable}</span></span>
|
||
))}
|
||
</span>
|
||
<span className="flex-shrink-0 font-mono text-[9px] text-muted-foreground">{stats.joinCount}개</span>
|
||
</div>
|
||
)}
|
||
<div className="flex h-[30px] items-center rounded-md px-2 text-[11px] transition-colors hover:bg-muted/30">
|
||
<span className="w-[76px] flex-shrink-0 text-[10px] font-medium text-muted-foreground">컴포넌트</span>
|
||
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold">
|
||
{stats.inputCount > 0 && <>입력 {stats.inputCount}</>}
|
||
{stats.gridCount > 0 && <>{stats.inputCount > 0 && " · "}그리드 {stats.gridCount}</>}
|
||
{stats.buttonCount > 0 && <>{(stats.inputCount > 0 || stats.gridCount > 0) && " · "}버튼 {stats.buttonCount}</>}
|
||
{stats.inputCount === 0 && stats.gridCount === 0 && stats.buttonCount === 0 && `${componentCount}개`}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="my-4 h-px bg-border/40" />
|
||
|
||
{/* 3. 테이블 섹션 */}
|
||
<div className="mb-4">
|
||
<div className="mb-2 flex items-center gap-1.5 border-b border-border/30 pb-2">
|
||
<div className="flex h-4 w-4 items-center justify-center rounded bg-blue-500/10">
|
||
<Database className="h-2.5 w-2.5 text-blue-500" />
|
||
</div>
|
||
<span className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">테이블</span>
|
||
<Badge variant="secondary" className="ml-auto h-4 rounded-full px-1.5 text-[9px] font-bold bg-blue-500/10 text-blue-500 hover:bg-blue-500/10">{stats.tableCount}</Badge>
|
||
</div>
|
||
<p className="mb-2 text-[10px] text-muted-foreground/70">컬럼 타입이나 조인을 변경하려면 "설정"을 눌러요</p>
|
||
<div className="space-y-1">
|
||
{currentMainTable && (
|
||
<div className="flex items-center gap-2.5 rounded-lg border border-border/40 bg-muted/20 p-2.5 transition-colors hover:border-border/60 hover:bg-muted/30">
|
||
<div className="h-6 w-[3px] flex-shrink-0 rounded-full bg-blue-500" />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate font-mono text-[11px] font-semibold">{currentMainTable}</div>
|
||
<div className="text-[9px] text-muted-foreground">메인 · {stats.fieldCount} 컬럼 사용중</div>
|
||
</div>
|
||
<Button variant="outline" size="sm" className="h-6 px-2.5 text-[10px] font-semibold border-border/50" onClick={() => handleOpenTableSetting(currentMainTable, currentMainTableLabel)}>설정</Button>
|
||
</div>
|
||
)}
|
||
{filterTables.map((ft, idx) => (
|
||
<div key={idx} className="flex items-center gap-2.5 rounded-lg border border-border/40 bg-muted/20 p-2.5 transition-colors hover:border-border/60 hover:bg-muted/30">
|
||
<div className="h-6 w-[3px] flex-shrink-0 rounded-full bg-emerald-500" />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate font-mono text-[11px] font-semibold">{ft.tableName}</div>
|
||
<div className="text-[9px] text-muted-foreground">필터{ft.filterKeyMapping ? ` · FK: ${ft.filterKeyMapping.filterTableColumn}` : ""}</div>
|
||
</div>
|
||
<Button variant="outline" size="sm" className="h-6 px-2.5 text-[10px] font-semibold border-border/50" onClick={() => handleOpenTableSetting(ft.tableName, ft.tableLabel)}>설정</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="my-4 h-px bg-border/40" />
|
||
|
||
{/* 4. 버튼 섹션 (읽기 전용) */}
|
||
<div className="mb-4">
|
||
<div className="mb-2 flex items-center gap-1.5 border-b border-border/30 pb-2">
|
||
<div className="flex h-4 w-4 items-center justify-center rounded bg-amber-500/10">
|
||
<MousePointer className="h-2.5 w-2.5 text-amber-500" />
|
||
</div>
|
||
<span className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">버튼</span>
|
||
<Badge variant="secondary" className="ml-auto h-4 rounded-full px-1.5 text-[9px] font-bold bg-amber-500/10 text-amber-500 hover:bg-amber-500/10">{stats.buttonCount}</Badge>
|
||
</div>
|
||
<p className="mb-2 text-[10px] text-muted-foreground/70">버튼 편집은 화면 디자이너에서 해요</p>
|
||
{buttonControls.length > 0 ? (
|
||
<div className="space-y-0">
|
||
{buttonControls.map((btn) => (
|
||
<div key={btn.id} className="flex items-center gap-2 border-b border-border/20 py-1.5 last:border-b-0">
|
||
<span
|
||
className="flex h-5 flex-shrink-0 items-center rounded px-2 text-[9px] font-bold"
|
||
style={{
|
||
backgroundColor: btn.backgroundColor ? `${btn.backgroundColor}20` : "hsl(var(--muted))",
|
||
color: btn.textColor || btn.backgroundColor || "hsl(var(--foreground))",
|
||
}}
|
||
>{btn.label}</span>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-[10px] text-muted-foreground">{btn.actionType?.toUpperCase() || "CUSTOM"}</div>
|
||
{btn.confirmMessage && <div className="truncate text-[9px] italic text-muted-foreground/60">"{btn.confirmMessage}"</div>}
|
||
</div>
|
||
{btn.linkedFlows && btn.linkedFlows.length > 0 && (
|
||
<Badge variant="secondary" className="h-4 rounded px-1.5 text-[8px] font-bold bg-violet-500/10 text-violet-500 hover:bg-violet-500/10">
|
||
플로우 {btn.linkedFlows.length}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="py-4 text-center text-[10px] text-muted-foreground/50">버튼이 없어요</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="my-4 h-px bg-border/40" />
|
||
|
||
{/* 5. 데이터 흐름 섹션 */}
|
||
<div className="mb-4">
|
||
<div className="mb-2 flex items-center gap-1.5 border-b border-border/30 pb-2">
|
||
<div className="flex h-4 w-4 items-center justify-center rounded bg-rose-500/10">
|
||
<ArrowRight className="h-2.5 w-2.5 text-rose-500" />
|
||
</div>
|
||
<span className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">데이터 흐름</span>
|
||
<Badge variant="secondary" className="ml-auto h-4 rounded-full px-1.5 text-[9px] font-bold bg-rose-500/10 text-rose-500 hover:bg-rose-500/10">{stats.flowCount}</Badge>
|
||
</div>
|
||
{dataFlows.length > 0 ? (
|
||
<div className="space-y-1">
|
||
{dataFlows.map((flow) => (
|
||
<div key={flow.id} className="flex items-center gap-2.5 rounded-lg border border-border/40 bg-muted/20 p-2.5">
|
||
<div className="h-6 w-[3px] flex-shrink-0 rounded-full bg-rose-500" />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-[11px] font-semibold">{flow.source_action || flow.flow_type} → {flow.target_screen_name || `화면 ${flow.target_screen_id}`}</div>
|
||
<div className="text-[9px] text-muted-foreground">{flow.flow_type}{flow.flow_label ? ` · ${flow.flow_label}` : ""}</div>
|
||
</div>
|
||
<Button variant="ghost" size="sm" className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive" onClick={async () => {
|
||
if (!confirm("정말 삭제하시겠습니까?")) return;
|
||
const res = await deleteDataFlow(flow.id);
|
||
if (res.success) { toast.success("삭제되었습니다."); loadData(); onSaveSuccess?.(); }
|
||
else toast.error("삭제 실패");
|
||
}}>
|
||
<Trash2 className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg border border-dashed border-border/40 py-5 text-center">
|
||
<ArrowRight className="mx-auto mb-1 h-4 w-4 text-muted-foreground/30" />
|
||
<div className="text-[11px] font-medium text-muted-foreground/60">데이터 흐름이 없어요</div>
|
||
<div className="text-[9px] text-muted-foreground/40">다른 화면으로 데이터를 전달하려면 추가해보세요</div>
|
||
</div>
|
||
)}
|
||
<Button variant="outline" size="sm" className="mt-2 h-7 w-full text-[10px] font-semibold border-border/40 text-muted-foreground" onClick={() => toast.info("데이터 흐름 추가는 준비 중이에요")}>
|
||
<Plus className="mr-1 h-3 w-3" />
|
||
흐름 추가
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="my-4 h-px bg-border/40" />
|
||
|
||
{/* 6. 플로우 연동 섹션 */}
|
||
<div>
|
||
<div className="mb-2 flex items-center gap-1.5 border-b border-border/30 pb-2">
|
||
<div className="flex h-4 w-4 items-center justify-center rounded bg-violet-500/10">
|
||
<Link2 className="h-2.5 w-2.5 text-violet-500" />
|
||
</div>
|
||
<span className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">플로우 연동</span>
|
||
<Badge variant="secondary" className="ml-auto h-4 rounded-full px-1.5 text-[9px] font-bold bg-violet-500/10 text-violet-500 hover:bg-violet-500/10">{linkedFlowCount}</Badge>
|
||
</div>
|
||
{linkedFlowCount > 0 ? (
|
||
<div className="space-y-1">
|
||
{buttonControls.filter(b => b.linkedFlows && b.linkedFlows.length > 0).flatMap(btn =>
|
||
(btn.linkedFlows || []).map(flow => (
|
||
<div key={`${btn.id}-${flow.id}`} className="flex items-center gap-2.5 rounded-lg border border-violet-500/10 bg-violet-500/[0.03] p-2.5">
|
||
<div className="h-6 w-[3px] flex-shrink-0 rounded-full bg-violet-500" />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-[11px] font-semibold">{flow.name || `플로우 #${flow.id}`}</div>
|
||
<div className="text-[9px] text-muted-foreground">{btn.label} 버튼 · {flow.timing === "before" ? "실행 전" : "실행 후"}</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="py-3 text-center text-[10px] text-muted-foreground/50">연동된 플로우가 없어요</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* CTA: 화면 디자이너 열기 */}
|
||
<div className="flex-shrink-0 border-t border-border/40 p-3">
|
||
<Button
|
||
className="h-8 w-full gap-1.5 bg-gradient-to-r from-primary to-blue-500 text-[11px] font-bold tracking-tight text-primary-foreground shadow-md shadow-primary/20 transition-all hover:shadow-lg hover:shadow-primary/30"
|
||
onClick={() => setShowDesignerModal(true)}
|
||
>
|
||
<ExternalLink className="h-3 w-3" />
|
||
화면 디자이너에서 편집
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 오른쪽: 화면 프리뷰 */}
|
||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-border/40">
|
||
<PreviewTab
|
||
screenId={currentScreenId}
|
||
screenName={currentScreenName}
|
||
companyCode={companyCode}
|
||
iframeKey={iframeKey}
|
||
canvasWidth={canvasSize.width}
|
||
canvasHeight={canvasSize.height}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* ScreenDesigner 전체 화면 - Dialog 중첩 시 오버레이 컴포지팅으로 깜빡임 발생하여 직접 렌더링 */}
|
||
{/* ScreenDesigner 자체 SlimToolbar에 "목록으로" 닫기 버튼이 있으므로 별도 X 버튼 불필요 */}
|
||
{showDesignerModal && (
|
||
<div className="bg-background fixed inset-0 z-[1000] flex flex-col">
|
||
<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();
|
||
setIframeKey(prev => prev + 1);
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 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) {
|
||
// 중복 테이블 제거 (tableName 기준)
|
||
const uniqueTables = tablesResult.data.filter(
|
||
(table, index, self) =>
|
||
index === self.findIndex((t) => t.tableName === table.tableName)
|
||
);
|
||
setAllTables(uniqueTables);
|
||
}
|
||
}
|
||
} 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-card/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-foreground">
|
||
<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-primary" />
|
||
<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-primary/10 border-primary/40"
|
||
: "bg-primary/10 border-primary/20 hover:bg-primary/10 hover:border-primary/40";
|
||
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-amber-100 border-orange-400"
|
||
: "bg-amber-50 border-orange-200 hover:bg-amber-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-muted border-input"
|
||
: "bg-muted border-border hover:bg-muted";
|
||
}
|
||
|
||
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-primary"
|
||
: isJoinKey ? "text-orange-800"
|
||
: isFilterKey ? "text-purple-800"
|
||
: "text-muted-foreground"
|
||
}`}>
|
||
{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-primary">필드</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-foreground">컬럼 설정</div>
|
||
|
||
{/* 화면 필드 정보 (필드인 경우만) */}
|
||
{isUsed && (
|
||
<>
|
||
<div className="space-y-1">
|
||
<span className="text-[10px] text-muted-foreground">화면 필드</span>
|
||
<div className="text-sm font-medium text-primary">
|
||
{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-muted-foreground">
|
||
{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-destructive border-destructive/30 hover:bg-destructive/10"
|
||
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-primary border-primary/40 hover:bg-primary/10"
|
||
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-muted text-muted-foreground border-input"}`}>
|
||
조인
|
||
</Badge>
|
||
<span className={`text-[10px] font-medium ${hasJoinSetting ? "text-orange-700" : "text-muted-foreground"}`}>
|
||
{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-muted-foreground hover:text-foreground" onClick={() => setEditingJoin(null)}>
|
||
취소
|
||
</Button>
|
||
<Button size="sm" className="h-5 px-1.5 text-[10px] bg-amber-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-amber-600 hover:text-orange-800" : "text-muted-foreground hover:text-foreground"}`}
|
||
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, idx) => (
|
||
<CommandItem
|
||
key={`${t.tableName}-${idx}`}
|
||
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-primary/10 p-4">
|
||
<div className="text-2xl font-bold text-primary">{stats.tableCount}</div>
|
||
<div className="text-xs text-primary">연결된 테이블</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-amber-50 p-4">
|
||
<div className="text-2xl font-bold text-amber-600">{stats.joinCount}</div>
|
||
<div className="text-xs text-orange-700">조인 설정</div>
|
||
</div>
|
||
<div className="rounded-lg border bg-emerald-50 p-4">
|
||
<div className="text-2xl font-bold text-emerald-600">{stats.filterCount}</div>
|
||
<div className="text-xs text-emerald-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-primary" />
|
||
메인 테이블
|
||
</h3>
|
||
</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-card overflow-hidden"
|
||
>
|
||
{/* 컴포넌트 헤더 */}
|
||
<div className="flex items-center justify-between px-3 py-2 bg-muted border-b">
|
||
<div className="flex items-center gap-2">
|
||
<Table2 className="h-4 w-4 text-primary" />
|
||
<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-muted text-[10px] font-medium text-muted-foreground 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-amber-50"
|
||
)}
|
||
>
|
||
{/* 필드명 (화면 표시) */}
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
<span className="font-medium text-foreground truncate">
|
||
{displayName}
|
||
</span>
|
||
{isJoinColumn && (
|
||
<Badge variant="outline" className="text-[9px] bg-amber-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-muted-foreground/70" />
|
||
</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-primary/10 border border-transparent hover:border-primary/40",
|
||
"transition-colors cursor-pointer group"
|
||
)}
|
||
>
|
||
<code className="text-primary font-mono text-[11px] truncate">{col}</code>
|
||
<Pencil className="h-3 w-3 text-muted-foreground/70 group-hover:text-primary 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-primary"
|
||
)}>
|
||
{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-primary" />
|
||
)}
|
||
</CommandItem>
|
||
))
|
||
)}
|
||
</CommandGroup>
|
||
</CommandList>
|
||
</Command>
|
||
</PopoverContent>
|
||
</Popover>
|
||
) : (
|
||
<code className="text-primary 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-primary/10 text-primary">
|
||
{mainTable}.{mapping.targetField}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-center">
|
||
<ArrowRight className="mx-auto h-3 w-3 text-muted-foreground/70" />
|
||
</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-emerald-50 text-emerald-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-muted"
|
||
>
|
||
{type}
|
||
<span className="rounded-full bg-muted/80 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-primary/10 text-primary",
|
||
flow.flow_type === "return" && "bg-emerald-50 text-emerald-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;
|
||
borderRadius?: 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,
|
||
borderRadius: webTypeConfig.borderRadius || config.borderRadius || style.borderRadius,
|
||
// 모달/네비게이션 관련 (화면 디자이너는 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.borderRadius !== undefined) {
|
||
comp.webTypeConfig.borderRadius = values.borderRadius;
|
||
comp.componentConfig.borderRadius = values.borderRadius;
|
||
comp.style.borderRadius = values.borderRadius;
|
||
}
|
||
|
||
// 액션 타입 업데이트
|
||
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: "데이터전달",
|
||
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-emerald-100 text-emerald-700 hover:bg-emerald-100 hover:text-emerald-700";
|
||
case "delete":
|
||
return "bg-destructive/10 text-destructive hover:bg-destructive/10 hover:text-destructive";
|
||
case "edit":
|
||
case "copy":
|
||
return "bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary";
|
||
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-muted text-foreground hover:bg-muted hover:text-foreground";
|
||
}
|
||
};
|
||
|
||
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-primary/20 bg-primary/10/30">
|
||
<div className="flex items-center gap-2 px-3 py-2 border-b border-primary/20 bg-primary/10/50 rounded-t-lg">
|
||
<MousePointer className="h-4 w-4 text-primary" />
|
||
<span className="text-sm font-semibold text-primary">버튼 액션 설정</span>
|
||
<Badge className="h-5 text-[10px] bg-primary text-white hover:bg-primary">
|
||
{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";
|
||
const currentBorderRadius = editedValues[btn.id]?.borderRadius ?? btn.borderRadius ?? "4px";
|
||
|
||
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 text-xs font-medium min-w-[60px] shrink-0"
|
||
style={{
|
||
backgroundColor: currentBgColor,
|
||
color: currentTextColor,
|
||
borderRadius: currentBorderRadius,
|
||
}}
|
||
>
|
||
{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-input hover:ring-2 hover:ring-blue-300 transition-all"
|
||
style={{ backgroundColor: preset.bg }}
|
||
title={preset.name}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 버튼 모서리 (borderRadius) */}
|
||
<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">
|
||
<Select
|
||
value={editedValues[btn.id]?.borderRadius ?? btn.borderRadius ?? "4px"}
|
||
onValueChange={(val) => setEditedValues(prev => ({
|
||
...prev,
|
||
[btn.id]: { ...prev[btn.id], borderRadius: val }
|
||
}))}
|
||
>
|
||
<SelectTrigger className="h-7 w-[100px] text-xs">
|
||
<SelectValue placeholder="선택" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="0px" className="text-xs">없음 (0px)</SelectItem>
|
||
<SelectItem value="2px" className="text-xs">약간 (2px)</SelectItem>
|
||
<SelectItem value="4px" className="text-xs">기본 (4px)</SelectItem>
|
||
<SelectItem value="6px" className="text-xs">둥글게 (6px)</SelectItem>
|
||
<SelectItem value="8px" className="text-xs">더 둥글게 (8px)</SelectItem>
|
||
<SelectItem value="12px" className="text-xs">많이 (12px)</SelectItem>
|
||
<SelectItem value="9999px" className="text-xs">원형</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<span className="text-[10px] text-muted-foreground">버튼 모서리 둥글기</span>
|
||
</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-foreground 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-destructive/10 flex items-center justify-center text-muted-foreground/70 hover:text-destructive"
|
||
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-emerald-600 text-white hover:bg-emerald-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-card/50 hover:bg-card">
|
||
{/* 플로우 이름 - 일반 텍스트 */}
|
||
<Workflow className="h-3.5 w-3.5 text-purple-500 shrink-0" />
|
||
<span className="text-xs font-medium text-foreground 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-emerald-500" />}
|
||
<span className="text-xs text-emerald-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">⚠</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-primary" />
|
||
{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-emerald-100 text-emerald-700 border-green-300" },
|
||
{ value: "update", label: "UPDATE", color: "bg-primary/10 text-primary border-primary/40" },
|
||
{ value: "delete", label: "DELETE", color: "bg-destructive/10 text-destructive border-destructive/30" },
|
||
].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-muted text-muted-foreground border-border hover:bg-muted"
|
||
)}
|
||
>
|
||
{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-input"
|
||
/>
|
||
<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">
|
||
<DialogTitle className="sr-only">플로우 편집기</DialogTitle>
|
||
<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-primary" />
|
||
<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-muted"
|
||
>
|
||
{loading && (
|
||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/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-card 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;
|
||
|