2026-01-09 17:03:00 +09:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
2026-01-09 17:03:00 +09:00
|
|
|
|
import {
|
|
|
|
|
|
Dialog,
|
|
|
|
|
|
DialogContent,
|
|
|
|
|
|
DialogDescription,
|
|
|
|
|
|
DialogHeader,
|
|
|
|
|
|
DialogTitle,
|
|
|
|
|
|
} from "@/components/ui/dialog";
|
|
|
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Table,
|
|
|
|
|
|
TableBody,
|
|
|
|
|
|
TableCell,
|
|
|
|
|
|
TableHead,
|
|
|
|
|
|
TableHeader,
|
|
|
|
|
|
TableRow,
|
|
|
|
|
|
} from "@/components/ui/table";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Popover,
|
|
|
|
|
|
PopoverContent,
|
|
|
|
|
|
PopoverTrigger,
|
|
|
|
|
|
} from "@/components/ui/popover";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Command,
|
|
|
|
|
|
CommandEmpty,
|
|
|
|
|
|
CommandGroup,
|
|
|
|
|
|
CommandInput,
|
|
|
|
|
|
CommandItem,
|
|
|
|
|
|
CommandList,
|
|
|
|
|
|
} from "@/components/ui/command";
|
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Database,
|
|
|
|
|
|
Link2,
|
|
|
|
|
|
GitBranch,
|
|
|
|
|
|
Columns3,
|
|
|
|
|
|
Eye,
|
|
|
|
|
|
Save,
|
|
|
|
|
|
Plus,
|
2026-01-13 13:28:50 +09:00
|
|
|
|
Minus,
|
2026-01-09 17:03:00 +09:00
|
|
|
|
Pencil,
|
|
|
|
|
|
Trash2,
|
|
|
|
|
|
RefreshCw,
|
|
|
|
|
|
Loader2,
|
|
|
|
|
|
Check,
|
|
|
|
|
|
ChevronsUpDown,
|
|
|
|
|
|
ExternalLink,
|
|
|
|
|
|
Table2,
|
|
|
|
|
|
ArrowRight,
|
|
|
|
|
|
Settings2,
|
2026-01-09 18:26:37 +09:00
|
|
|
|
ChevronDown,
|
|
|
|
|
|
ChevronRight,
|
|
|
|
|
|
Filter,
|
2026-01-13 13:28:50 +09:00
|
|
|
|
RotateCcw,
|
|
|
|
|
|
X,
|
|
|
|
|
|
Zap,
|
|
|
|
|
|
MousePointer,
|
|
|
|
|
|
Workflow,
|
2026-01-09 17:03:00 +09:00
|
|
|
|
} from "lucide-react";
|
|
|
|
|
|
import {
|
|
|
|
|
|
getDataFlows,
|
|
|
|
|
|
createDataFlow,
|
|
|
|
|
|
updateDataFlow,
|
|
|
|
|
|
deleteDataFlow,
|
|
|
|
|
|
DataFlow,
|
2026-01-09 18:26:37 +09:00
|
|
|
|
getMultipleScreenLayoutSummary,
|
|
|
|
|
|
LayoutItem,
|
2026-01-14 14:35:27 +09:00
|
|
|
|
getScreenGroup,
|
2026-01-09 17:03:00 +09:00
|
|
|
|
} from "@/lib/api/screenGroup";
|
2026-01-13 13:28:50 +09:00
|
|
|
|
import { tableManagementApi, ColumnTypeInfo, TableInfo, ColumnSettings } from "@/lib/api/tableManagement";
|
|
|
|
|
|
import { screenApi } from "@/lib/api/screen";
|
|
|
|
|
|
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
|
2026-01-14 14:35:27 +09:00
|
|
|
|
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";
|
2026-01-13 13:28:50 +09:00
|
|
|
|
import {
|
|
|
|
|
|
Select,
|
|
|
|
|
|
SelectContent,
|
|
|
|
|
|
SelectItem,
|
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
|
SelectValue,
|
|
|
|
|
|
} from "@/components/ui/select";
|
2026-01-09 17:03:00 +09:00
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 타입 정의
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
interface FilterTableInfo {
|
|
|
|
|
|
tableName: string;
|
|
|
|
|
|
tableLabel?: string;
|
|
|
|
|
|
filterColumns?: string[];
|
2026-01-13 13:28:50 +09:00
|
|
|
|
// 필터 키 매핑 정보 (메인 테이블.컬럼 → 필터 테이블.컬럼)
|
|
|
|
|
|
filterKeyMapping?: {
|
|
|
|
|
|
mainTableColumn: string; // 메인 테이블의 컬럼 (leftColumn)
|
|
|
|
|
|
mainTableColumnLabel?: string;
|
|
|
|
|
|
filterTableColumn: string; // 필터 테이블의 컬럼 (foreignKey)
|
|
|
|
|
|
filterTableColumnLabel?: string;
|
|
|
|
|
|
};
|
2026-01-09 17:03:00 +09:00
|
|
|
|
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;
|
2026-01-09 18:26:37 +09:00
|
|
|
|
companyCode?: string; // 프리뷰용 회사 코드
|
2026-01-09 17:03:00 +09:00
|
|
|
|
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,
|
2026-01-09 18:26:37 +09:00
|
|
|
|
companyCode,
|
2026-01-09 17:03:00 +09:00
|
|
|
|
mainTable,
|
|
|
|
|
|
mainTableLabel,
|
|
|
|
|
|
filterTables = [],
|
|
|
|
|
|
fieldMappings = [],
|
|
|
|
|
|
componentCount = 0,
|
|
|
|
|
|
onSaveSuccess,
|
|
|
|
|
|
}: ScreenSettingModalProps) {
|
|
|
|
|
|
const [activeTab, setActiveTab] = useState("overview");
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
|
2026-01-09 18:26:37 +09:00
|
|
|
|
const [layoutItems, setLayoutItems] = useState<LayoutItem[]>([]);
|
2026-01-13 13:28:50 +09:00
|
|
|
|
const [iframeKey, setIframeKey] = useState(0); // iframe 새로고침용 키
|
2026-01-14 14:35:27 +09:00
|
|
|
|
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]);
|
2026-01-09 17:03:00 +09:00
|
|
|
|
|
|
|
|
|
|
// 데이터 로드
|
|
|
|
|
|
const loadData = useCallback(async () => {
|
2026-01-14 14:35:27 +09:00
|
|
|
|
if (!currentScreenId) return;
|
2026-01-09 17:03:00 +09:00
|
|
|
|
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
2026-01-09 18:26:37 +09:00
|
|
|
|
// 1. 해당 화면에서 시작하는 데이터 흐름 로드
|
2026-01-14 14:35:27 +09:00
|
|
|
|
const flowsResponse = await getDataFlows({ sourceScreenId: currentScreenId });
|
2026-01-09 17:03:00 +09:00
|
|
|
|
if (flowsResponse.success && flowsResponse.data) {
|
|
|
|
|
|
setDataFlows(flowsResponse.data);
|
|
|
|
|
|
}
|
2026-01-09 18:26:37 +09:00
|
|
|
|
|
|
|
|
|
|
// 2. 화면 레이아웃 요약 정보 로드 (컴포넌트 컬럼 정보 포함)
|
2026-01-14 14:35:27 +09:00
|
|
|
|
const layoutResponse = await getMultipleScreenLayoutSummary([currentScreenId]);
|
2026-01-09 18:26:37 +09:00
|
|
|
|
if (layoutResponse.success && layoutResponse.data) {
|
2026-01-14 14:35:27 +09:00
|
|
|
|
const screenLayout = layoutResponse.data[currentScreenId];
|
2026-01-09 18:26:37 +09:00
|
|
|
|
setLayoutItems(screenLayout?.layoutItems || []);
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// 캔버스 크기 저장 (화면 프리뷰에 사용)
|
|
|
|
|
|
setCanvasSize({
|
|
|
|
|
|
width: screenLayout?.canvasWidth || 0,
|
|
|
|
|
|
height: screenLayout?.canvasHeight || 0,
|
|
|
|
|
|
});
|
2026-01-09 18:26:37 +09:00
|
|
|
|
}
|
2026-01-09 17:03:00 +09:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("데이터 로드 실패:", error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
2026-01-14 14:35:27 +09:00
|
|
|
|
}, [currentScreenId]);
|
2026-01-09 17:03:00 +09:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-01-14 14:35:27 +09:00
|
|
|
|
if (isOpen && currentScreenId) {
|
2026-01-09 17:03:00 +09:00
|
|
|
|
loadData();
|
|
|
|
|
|
}
|
2026-01-14 14:35:27 +09:00
|
|
|
|
}, [isOpen, currentScreenId, loadData]);
|
2026-01-09 17:03:00 +09:00
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
// 새로고침 (데이터 + iframe)
|
|
|
|
|
|
const handleRefresh = useCallback(() => {
|
2026-01-09 17:03:00 +09:00
|
|
|
|
loadData();
|
2026-01-13 13:28:50 +09:00
|
|
|
|
setIframeKey(prev => prev + 1); // iframe 새로고침
|
|
|
|
|
|
}, [loadData]);
|
2026-01-09 17:03:00 +09:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-01-14 14:35:27 +09:00
|
|
|
|
<>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<DialogContent className="flex h-[90vh] max-h-[950px] w-[98vw] max-w-[1600px] flex-col">
|
2026-01-09 17:03:00 +09:00
|
|
|
|
<DialogHeader className="flex-shrink-0">
|
|
|
|
|
|
<DialogTitle className="flex items-center gap-2 text-lg">
|
|
|
|
|
|
<Settings2 className="h-5 w-5 text-blue-500" />
|
2026-01-14 14:35:27 +09:00
|
|
|
|
화면 설정:
|
|
|
|
|
|
{groupScreens.length > 1 ? (
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={currentScreenId.toString()}
|
|
|
|
|
|
onValueChange={(value) => handleScreenChange(parseInt(value, 10))}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger className="h-8 w-auto min-w-[200px] max-w-[400px] text-base font-semibold">
|
|
|
|
|
|
<SelectValue placeholder="화면 선택" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
{groupScreens.map((screen) => (
|
|
|
|
|
|
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
|
|
|
|
|
|
{screen.screen_name}
|
|
|
|
|
|
{screen.screen_role && (
|
|
|
|
|
|
<span className="ml-2 text-xs text-muted-foreground">
|
|
|
|
|
|
({screen.screen_role})
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span>{currentScreenName}</span>
|
|
|
|
|
|
)}
|
2026-01-09 17:03:00 +09:00
|
|
|
|
</DialogTitle>
|
|
|
|
|
|
<DialogDescription className="text-sm">
|
|
|
|
|
|
화면의 필드 매핑, 테이블 연결, 데이터 흐름을 확인하고 설정합니다.
|
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
{/* 2컬럼 레이아웃: 왼쪽 탭(좁게) + 오른쪽 프리뷰(넓게) */}
|
|
|
|
|
|
<div className="flex min-h-0 flex-1 gap-3">
|
|
|
|
|
|
{/* 왼쪽: 탭 컨텐츠 (40%) */}
|
|
|
|
|
|
<div className="flex min-h-0 w-[40%] flex-col rounded-lg border bg-white">
|
|
|
|
|
|
<Tabs
|
|
|
|
|
|
value={activeTab}
|
|
|
|
|
|
onValueChange={setActiveTab}
|
|
|
|
|
|
className="flex min-h-0 flex-1 flex-col"
|
2026-01-09 17:03:00 +09:00
|
|
|
|
>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<div className="flex flex-shrink-0 items-center justify-between border-b p-2">
|
|
|
|
|
|
<TabsList className="h-8">
|
|
|
|
|
|
<TabsTrigger value="overview" className="gap-1 text-xs px-2">
|
|
|
|
|
|
<Database className="h-3 w-3" />
|
|
|
|
|
|
개요
|
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
|
<TabsTrigger value="control-management" className="gap-1 text-xs px-2">
|
|
|
|
|
|
<Zap className="h-3 w-3" />
|
|
|
|
|
|
제어 관리
|
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
|
<TabsTrigger value="data-flow" className="gap-1 text-xs px-2">
|
|
|
|
|
|
<GitBranch className="h-3 w-3" />
|
|
|
|
|
|
데이터 흐름
|
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
|
</TabsList>
|
2026-01-14 14:35:27 +09:00
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={handleRefresh}
|
|
|
|
|
|
className="h-7 w-7 p-0"
|
|
|
|
|
|
title="새로고침"
|
|
|
|
|
|
>
|
|
|
|
|
|
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => setShowDesignerModal(true)}
|
|
|
|
|
|
className="h-7 px-2 text-xs gap-1"
|
|
|
|
|
|
title="화면 디자이너에서 상세 편집"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ExternalLink className="h-3 w-3" />
|
|
|
|
|
|
디자이너
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
</div>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
{/* 탭 1: 화면 개요 */}
|
|
|
|
|
|
<TabsContent value="overview" className="mt-0 min-h-0 flex-1 overflow-auto p-3">
|
|
|
|
|
|
<OverviewTab
|
2026-01-14 14:35:27 +09:00
|
|
|
|
screenId={currentScreenId}
|
|
|
|
|
|
screenName={currentScreenName}
|
|
|
|
|
|
mainTable={currentMainTable}
|
|
|
|
|
|
mainTableLabel={currentMainTableLabel}
|
2026-01-13 13:28:50 +09:00
|
|
|
|
filterTables={filterTables}
|
|
|
|
|
|
fieldMappings={fieldMappings}
|
|
|
|
|
|
componentCount={componentCount}
|
|
|
|
|
|
dataFlows={dataFlows}
|
|
|
|
|
|
layoutItems={layoutItems}
|
|
|
|
|
|
loading={loading}
|
|
|
|
|
|
onRefresh={handleRefresh}
|
2026-01-14 14:35:27 +09:00
|
|
|
|
onOpenTableSetting={handleOpenTableSetting}
|
2026-01-13 13:28:50 +09:00
|
|
|
|
/>
|
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 탭 2: 제어 관리 */}
|
|
|
|
|
|
<TabsContent value="control-management" className="mt-0 min-h-0 flex-1 overflow-auto p-3">
|
|
|
|
|
|
<ControlManagementTab
|
2026-01-14 14:35:27 +09:00
|
|
|
|
screenId={currentScreenId}
|
|
|
|
|
|
groupId={groupId}
|
2026-01-13 13:28:50 +09:00
|
|
|
|
layoutItems={layoutItems}
|
|
|
|
|
|
loading={loading}
|
|
|
|
|
|
onRefresh={handleRefresh}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 탭 3: 데이터 흐름 */}
|
|
|
|
|
|
<TabsContent value="data-flow" className="mt-0 min-h-0 flex-1 overflow-auto p-3">
|
|
|
|
|
|
<DataFlowTab
|
2026-01-14 14:35:27 +09:00
|
|
|
|
screenId={currentScreenId}
|
2026-01-13 13:28:50 +09:00
|
|
|
|
groupId={groupId}
|
|
|
|
|
|
dataFlows={dataFlows}
|
|
|
|
|
|
loading={loading}
|
|
|
|
|
|
onReload={loadData}
|
|
|
|
|
|
onSaveSuccess={onSaveSuccess}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
</Tabs>
|
|
|
|
|
|
</div>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
{/* 오른쪽: 화면 프리뷰 (60%, 항상 표시) */}
|
|
|
|
|
|
<div className="flex min-h-0 w-[60%] flex-col overflow-hidden rounded-lg border bg-white">
|
2026-01-14 14:35:27 +09:00
|
|
|
|
<PreviewTab
|
|
|
|
|
|
screenId={currentScreenId}
|
|
|
|
|
|
screenName={currentScreenName}
|
|
|
|
|
|
companyCode={companyCode}
|
|
|
|
|
|
iframeKey={iframeKey}
|
|
|
|
|
|
canvasWidth={canvasSize.width}
|
|
|
|
|
|
canvasHeight={canvasSize.height}
|
|
|
|
|
|
/>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
2026-01-14 14:35:27 +09:00
|
|
|
|
|
|
|
|
|
|
{/* ScreenDesigner 전체 화면 모달 */}
|
|
|
|
|
|
<Dialog open={showDesignerModal} onOpenChange={setShowDesignerModal}>
|
|
|
|
|
|
<DialogContent className="max-w-[98vw] h-[95vh] p-0 overflow-hidden">
|
|
|
|
|
|
<div className="flex flex-col h-full">
|
|
|
|
|
|
<ScreenDesigner
|
|
|
|
|
|
selectedScreen={{
|
|
|
|
|
|
screenId: currentScreenId,
|
|
|
|
|
|
screenCode: `screen_${currentScreenId}`,
|
|
|
|
|
|
screenName: currentScreenName,
|
|
|
|
|
|
tableName: currentMainTable || "",
|
|
|
|
|
|
companyCode: companyCode || "*",
|
|
|
|
|
|
description: "",
|
|
|
|
|
|
isActive: "Y" as const,
|
|
|
|
|
|
createdDate: new Date(),
|
|
|
|
|
|
updatedDate: new Date(),
|
|
|
|
|
|
}}
|
|
|
|
|
|
onBackToList={async () => {
|
|
|
|
|
|
setShowDesignerModal(false);
|
|
|
|
|
|
// 디자이너에서 저장 후 모달 닫으면 데이터 새로고침
|
|
|
|
|
|
await loadData();
|
|
|
|
|
|
// 데이터 로드 완료 후 iframe 갱신
|
|
|
|
|
|
setIframeKey(prev => prev + 1);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
|
|
{/* TableSettingModal */}
|
|
|
|
|
|
{tableSettingTarget && (
|
|
|
|
|
|
<TableSettingModal
|
|
|
|
|
|
isOpen={showTableSettingModal}
|
|
|
|
|
|
onClose={handleCloseTableSetting}
|
|
|
|
|
|
tableName={tableSettingTarget.tableName}
|
|
|
|
|
|
tableLabel={tableSettingTarget.tableLabel}
|
|
|
|
|
|
screenId={currentScreenId}
|
|
|
|
|
|
onSaveSuccess={() => {
|
|
|
|
|
|
handleRefresh();
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 18:26:37 +09:00
|
|
|
|
// ============================================================
|
2026-01-13 13:28:50 +09:00
|
|
|
|
// 통합 테이블 컬럼 아코디언 컴포넌트
|
2026-01-09 18:26:37 +09:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
interface ColumnMapping {
|
|
|
|
|
|
columnName: string;
|
|
|
|
|
|
fieldLabel?: string;
|
|
|
|
|
|
order: number; // 화면 순서 (y 좌표 기준)
|
2026-01-09 18:26:37 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
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;
|
2026-01-14 14:35:27 +09:00
|
|
|
|
usedFields?: Set<string>; // 화면에서 사용 중인 컬럼 목록
|
2026-01-13 13:28:50 +09:00
|
|
|
|
|
|
|
|
|
|
// 필터 테이블 전용 props (optional)
|
|
|
|
|
|
mainTable?: string; // 메인 테이블명 (필터 테이블에서 필터 연결 정보 표시용)
|
|
|
|
|
|
filterKeyMapping?: FilterKeyMapping;
|
|
|
|
|
|
joinColumnRefs?: JoinColumnRef[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function TableColumnAccordion({
|
|
|
|
|
|
tableName,
|
|
|
|
|
|
tableLabel,
|
|
|
|
|
|
tableType,
|
|
|
|
|
|
columnMappings = [],
|
|
|
|
|
|
onColumnChange,
|
|
|
|
|
|
onColumnReorder,
|
|
|
|
|
|
onJoinSettingSaved,
|
2026-01-14 14:35:27 +09:00
|
|
|
|
usedFields = new Set(),
|
2026-01-13 13:28:50 +09:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-01-09 18:26:37 +09:00
|
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
|
|
const [columns, setColumns] = useState<ColumnTypeInfo[]>([]);
|
|
|
|
|
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
2026-01-13 13:28:50 +09:00
|
|
|
|
|
|
|
|
|
|
// 편집 중인 필드
|
|
|
|
|
|
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]);
|
2026-01-09 18:26:37 +09:00
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
// 아코디언 열릴 때 테이블 컬럼 + 전체 테이블 목록 로드
|
2026-01-09 18:26:37 +09:00
|
|
|
|
const handleToggle = async () => {
|
|
|
|
|
|
const newIsOpen = !isOpen;
|
|
|
|
|
|
setIsOpen(newIsOpen);
|
|
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
if (newIsOpen && columns.length === 0 && tableName) {
|
2026-01-09 18:26:37 +09:00
|
|
|
|
setLoadingColumns(true);
|
|
|
|
|
|
try {
|
2026-01-13 13:28:50 +09:00
|
|
|
|
const result = await tableManagementApi.getColumnList(tableName);
|
2026-01-09 18:26:37 +09:00
|
|
|
|
if (result.success && result.data && result.data.columns) {
|
|
|
|
|
|
setColumns(result.data.columns);
|
|
|
|
|
|
}
|
2026-01-13 13:28:50 +09:00
|
|
|
|
|
|
|
|
|
|
if (allTables.length === 0) {
|
|
|
|
|
|
const tablesResult = await tableManagementApi.getTableList();
|
|
|
|
|
|
if (tablesResult.success && tablesResult.data) {
|
|
|
|
|
|
setAllTables(tablesResult.data);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-09 18:26:37 +09:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("테이블 컬럼 로드 실패:", error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingColumns(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-01-13 13:28:50 +09:00
|
|
|
|
|
|
|
|
|
|
// 참조 테이블 선택 시 해당 테이블의 컬럼 로드
|
|
|
|
|
|
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);
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// usedFields에서도 확인 (bindField 등에서 가져온 사용 컬럼)
|
|
|
|
|
|
const isUsed = !!mapping || usedFields.has(colNameLower) ||
|
|
|
|
|
|
Array.from(usedFields).some(f => f.toLowerCase() === colNameLower);
|
2026-01-13 13:28:50 +09:00
|
|
|
|
|
|
|
|
|
|
return { isFilterKey, isJoinKey, joinRef, isUsed, mapping };
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const ThemeIcon = themeIcon;
|
2026-01-09 18:26:37 +09:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<div className={`rounded-lg border bg-${themeColor}-50/30 overflow-hidden`}>
|
|
|
|
|
|
{/* 헤더 */}
|
2026-01-09 18:26:37 +09:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={handleToggle}
|
2026-01-13 13:28:50 +09:00
|
|
|
|
className={`w-full flex items-center gap-3 p-3 hover:bg-${themeColor}-50/50 transition-colors text-left`}
|
2026-01-09 18:26:37 +09:00
|
|
|
|
>
|
|
|
|
|
|
{isOpen ? (
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<ChevronDown className={`h-4 w-4 text-${themeColor}-500 flex-shrink-0`} />
|
2026-01-09 18:26:37 +09:00
|
|
|
|
) : (
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<ChevronRight className={`h-4 w-4 text-${themeColor}-500 flex-shrink-0`} />
|
2026-01-09 18:26:37 +09:00
|
|
|
|
)}
|
|
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<ThemeIcon className={`h-4 w-4 text-${themeColor}-500 flex-shrink-0`} />
|
2026-01-09 18:26:37 +09:00
|
|
|
|
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<div className="font-medium truncate">{tableLabel || tableName}</div>
|
|
|
|
|
|
{tableLabel && tableName !== tableLabel && (
|
|
|
|
|
|
<div className="text-xs text-muted-foreground truncate">{tableName}</div>
|
2026-01-09 18:26:37 +09:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<Badge variant="outline" className={`bg-${themeColor}-100 text-${themeColor}-700 text-xs flex-shrink-0`}>
|
|
|
|
|
|
{themeBadge}
|
2026-01-09 18:26:37 +09:00
|
|
|
|
</Badge>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 요약 정보 */}
|
|
|
|
|
|
<div className="text-xs text-muted-foreground flex-shrink-0">
|
2026-01-13 13:28:50 +09:00
|
|
|
|
{isMain ? (
|
|
|
|
|
|
columns.length > 0 && `${columns.length}개 컬럼`
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{hasFilterKey && `${(filterKeyMapping ? 1 : 0)}개 필터`}
|
|
|
|
|
|
{hasJoinRefs && hasFilterKey && " / "}
|
|
|
|
|
|
{hasJoinRefs && `${joinColumnRefs!.length}개 조인`}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-01-09 18:26:37 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 펼쳐진 내용 */}
|
|
|
|
|
|
{isOpen && (
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<div className={`border-t border-${themeColor}-100 p-3 space-y-3 bg-white/50`}>
|
|
|
|
|
|
{/* 필터 연결 정보 (필터 테이블만) */}
|
|
|
|
|
|
{!isMain && filterKeyMapping && (
|
|
|
|
|
|
<div className="flex items-center gap-1 text-xs">
|
|
|
|
|
|
<Badge variant="outline" className="h-4 px-1 text-[8px] bg-purple-200 text-purple-700 border-purple-300">필터</Badge>
|
|
|
|
|
|
<span className="font-mono text-purple-700">
|
|
|
|
|
|
{mainTable}.{filterKeyMapping.mainTableColumnLabel || filterKeyMapping.mainTableColumn}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-muted-foreground">=</span>
|
|
|
|
|
|
<span className="font-mono text-purple-700">
|
|
|
|
|
|
{tableName}.{filterKeyMapping.filterTableColumnLabel || filterKeyMapping.filterTableColumn}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-01-09 18:26:37 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 테이블 컬럼 정보 */}
|
|
|
|
|
|
<div className="space-y-2">
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div className="flex items-center gap-1 text-xs font-medium text-gray-700">
|
|
|
|
|
|
<Table2 className="h-3 w-3" />
|
|
|
|
|
|
테이블 컬럼 ({loadingColumns ? "로딩중..." : `${columns.length}개`})
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{columnMappings.length > 0 && (
|
|
|
|
|
|
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
|
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
|
|
|
|
|
<span>화면에서 사용 ({columnMappings.length}개)</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-01-09 18:26:37 +09:00
|
|
|
|
</div>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
|
2026-01-09 18:26:37 +09:00
|
|
|
|
{loadingColumns ? (
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<div className="flex items-center justify-center py-4">
|
|
|
|
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
2026-01-09 18:26:37 +09:00
|
|
|
|
</div>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
) : sortedColumns.length > 0 ? (
|
|
|
|
|
|
<div className="flex gap-3 items-stretch">
|
|
|
|
|
|
{/* 왼쪽: 컬럼 목록 */}
|
|
|
|
|
|
<div className="flex-1 space-y-1 pr-1 max-h-[350px] overflow-y-auto">
|
|
|
|
|
|
{(() => {
|
|
|
|
|
|
// 드래그 중일 때 로컬 순서 적용
|
|
|
|
|
|
let displayColumns = sortedColumns;
|
|
|
|
|
|
if (localColumnOrder && localColumnOrder.length > 0) {
|
|
|
|
|
|
// 사용 중인 컬럼들을 localColumnOrder에 따라 재정렬
|
|
|
|
|
|
const usedCols = sortedColumns.filter(col => columnMappingMap.has(col.columnName.toLowerCase()));
|
|
|
|
|
|
const unusedCols = sortedColumns.filter(col => !columnMappingMap.has(col.columnName.toLowerCase()));
|
|
|
|
|
|
|
|
|
|
|
|
const reorderedUsed = localColumnOrder
|
|
|
|
|
|
.map(name => usedCols.find(col => col.columnName.toLowerCase() === name.toLowerCase()))
|
|
|
|
|
|
.filter(Boolean) as typeof usedCols;
|
|
|
|
|
|
|
|
|
|
|
|
displayColumns = [...reorderedUsed, ...unusedCols];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return displayColumns.map((col, cIdx) => {
|
|
|
|
|
|
const colNameLower = col.columnName.toLowerCase();
|
|
|
|
|
|
const { isFilterKey, isJoinKey, isUsed, mapping } = getColumnState(colNameLower);
|
|
|
|
|
|
const isSelected = editingField === (mapping?.fieldLabel || col.columnName);
|
|
|
|
|
|
const isDragging = draggedIndex === cIdx;
|
|
|
|
|
|
|
|
|
|
|
|
// 드래그 가능 여부 (사용 중인 컬럼만)
|
|
|
|
|
|
const canDrag = isUsed && !!onColumnReorder;
|
|
|
|
|
|
|
|
|
|
|
|
// 스타일 결정
|
|
|
|
|
|
let baseClass = "";
|
|
|
|
|
|
let leftBorderClass = "";
|
|
|
|
|
|
|
|
|
|
|
|
if (isUsed) {
|
|
|
|
|
|
baseClass = isSelected
|
|
|
|
|
|
? "bg-blue-100 border-blue-300"
|
|
|
|
|
|
: "bg-blue-50 border-blue-200 hover:bg-blue-100 hover:border-blue-300";
|
|
|
|
|
|
if (isJoinKey) {
|
|
|
|
|
|
leftBorderClass = "border-l-4 border-l-orange-500";
|
|
|
|
|
|
} else if (isFilterKey) {
|
|
|
|
|
|
leftBorderClass = "border-l-4 border-l-purple-400";
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (isJoinKey) {
|
|
|
|
|
|
baseClass = isSelected
|
|
|
|
|
|
? "bg-orange-100 border-orange-400"
|
|
|
|
|
|
: "bg-orange-50 border-orange-200 hover:bg-orange-100 hover:border-orange-300";
|
|
|
|
|
|
} else if (isFilterKey) {
|
|
|
|
|
|
baseClass = isSelected
|
|
|
|
|
|
? "bg-purple-100 border-purple-400"
|
|
|
|
|
|
: "bg-purple-50 border-purple-200 hover:bg-purple-100 hover:border-purple-300";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
baseClass = isSelected
|
|
|
|
|
|
? "bg-gray-100 border-gray-400"
|
|
|
|
|
|
: "bg-gray-50 border-gray-200 hover:bg-gray-100";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={cIdx}
|
|
|
|
|
|
draggable={canDrag}
|
|
|
|
|
|
onDragStart={canDrag ? (e) => handleDragStart(e, cIdx) : undefined}
|
|
|
|
|
|
onDragOver={canDrag ? (e) => handleDragOver(e, cIdx) : undefined}
|
|
|
|
|
|
onDrop={canDrag ? handleDrop : undefined}
|
|
|
|
|
|
onDragEnd={canDrag ? handleDragEnd : undefined}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setEditingField(mapping?.fieldLabel || col.columnName);
|
|
|
|
|
|
setEditingJoin(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className={`flex items-center justify-between gap-2 text-xs rounded px-2 py-1.5 border transition-all cursor-pointer ${baseClass} ${leftBorderClass} ${isDragging ? "opacity-50 scale-95" : ""} ${canDrag ? "cursor-grab active:cursor-grabbing" : ""}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className={`font-medium truncate flex-1 min-w-0 ${
|
|
|
|
|
|
isUsed ? "text-blue-700"
|
|
|
|
|
|
: isJoinKey ? "text-orange-800"
|
|
|
|
|
|
: isFilterKey ? "text-purple-800"
|
|
|
|
|
|
: "text-gray-500"
|
|
|
|
|
|
}`}>
|
|
|
|
|
|
{col.displayName || col.columnName}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
|
|
|
|
{isFilterKey && (
|
|
|
|
|
|
<Badge variant="outline" className="h-4 px-1 text-[8px] bg-purple-200 text-purple-700 border-purple-300">필터</Badge>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{isJoinKey && (
|
|
|
|
|
|
<Badge variant="outline" className="h-4 px-1 text-[8px] bg-orange-200 text-orange-700 border-orange-300">조인</Badge>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{isUsed && (
|
|
|
|
|
|
<Badge className="text-white text-[8px] px-1 py-0 h-4 bg-blue-500">필드</Badge>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span className="text-muted-foreground text-[10px] w-20 truncate text-right" title={col.dataType}>
|
|
|
|
|
|
{col.dataType?.split("(")[0]}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
})()}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 오른쪽: 컬럼 설정 패널 */}
|
|
|
|
|
|
<div className="w-52 border-l pl-3 flex-shrink-0 max-h-[350px] overflow-y-auto">
|
|
|
|
|
|
{editingField ? (() => {
|
|
|
|
|
|
const selectedMapping = columnMappings.find(m => m.fieldLabel === editingField);
|
|
|
|
|
|
const selectedColumn = selectedMapping
|
|
|
|
|
|
? columns.find(c => c.columnName.toLowerCase() === selectedMapping.columnName?.toLowerCase())
|
|
|
|
|
|
: columns.find(c => (c.displayName || c.columnName) === editingField || c.columnName === editingField);
|
|
|
|
|
|
const colNameLower = selectedColumn?.columnName?.toLowerCase() || editingField.toLowerCase();
|
|
|
|
|
|
const { isFilterKey, isJoinKey, joinRef, isUsed } = getColumnState(colNameLower);
|
|
|
|
|
|
|
|
|
|
|
|
// 조인 정보 - joinColumnRefs에서 먼저 찾고, 없으면 selectedColumn에서 가져옴
|
|
|
|
|
|
const hasJoinSetting = isJoinKey || !!selectedColumn?.referenceTable;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<div className="text-xs font-medium text-gray-700">컬럼 설정</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 화면 필드 정보 (필드인 경우만) */}
|
|
|
|
|
|
{isUsed && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">화면 필드</span>
|
|
|
|
|
|
<div className="text-sm font-medium text-blue-700">
|
|
|
|
|
|
{selectedColumn?.displayName || selectedMapping?.columnName || editingField}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">현재 컬럼</span>
|
|
|
|
|
|
<div className="text-sm font-mono text-gray-600">
|
|
|
|
|
|
{selectedMapping?.columnName || "-"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">컬럼 변경</span>
|
|
|
|
|
|
<Popover>
|
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
|
<Button variant="outline" size="sm" className="w-full justify-between h-7 text-xs">
|
|
|
|
|
|
{selectedColumn?.displayName || selectedMapping?.columnName || "컬럼 선택"}
|
|
|
|
|
|
<ChevronsUpDown className="h-3 w-3 opacity-50" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
|
<PopoverContent className="p-0 w-48" align="start">
|
|
|
|
|
|
<Command>
|
|
|
|
|
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
|
|
|
|
|
<CommandList className="max-h-32">
|
|
|
|
|
|
<CommandEmpty className="text-xs py-2">없음</CommandEmpty>
|
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
|
{columns.map((c) => (
|
|
|
|
|
|
<CommandItem
|
|
|
|
|
|
key={c.columnName}
|
|
|
|
|
|
value={c.displayName || c.columnName}
|
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
|
if (onColumnChange && selectedMapping) {
|
|
|
|
|
|
onColumnChange(editingField, selectedMapping.columnName, c.columnName);
|
|
|
|
|
|
}
|
|
|
|
|
|
setEditingField(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check className={`mr-2 h-3 w-3 ${c.columnName.toLowerCase() === selectedMapping?.columnName?.toLowerCase() ? "opacity-100" : "opacity-0"}`} />
|
|
|
|
|
|
{c.displayName || c.columnName}
|
|
|
|
|
|
</CommandItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
|
</CommandList>
|
|
|
|
|
|
</Command>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 필드에서 제거 */}
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="w-full h-7 text-xs text-red-600 border-red-300 hover:bg-red-50"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
if (selectedMapping && onColumnChange) {
|
|
|
|
|
|
onColumnChange(selectedMapping.fieldLabel!, selectedMapping.columnName, "__REMOVE_FIELD__");
|
|
|
|
|
|
toast.success(`"${selectedColumn?.displayName || selectedMapping.columnName}" 필드가 제거되었습니다.`);
|
|
|
|
|
|
setEditingField(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="h-3 w-3 mr-1" />
|
|
|
|
|
|
필드에서 제거
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 컬럼 기본 정보 (필드가 아닌 경우) */}
|
|
|
|
|
|
{!isUsed && (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<div className="text-xs text-muted-foreground">
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<span className="text-[10px]">컬럼명</span>
|
|
|
|
|
|
<div className="font-mono">{selectedColumn?.columnName || editingField}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1 mt-2">
|
|
|
|
|
|
<span className="text-[10px]">데이터 타입</span>
|
|
|
|
|
|
<div>{selectedColumn?.dataType || "-"}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="w-full h-7 text-xs text-blue-600 border-blue-300 hover:bg-blue-50"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
if (selectedColumn?.columnName && onColumnChange) {
|
|
|
|
|
|
onColumnChange("__NEW_FIELD__", "", selectedColumn.columnName);
|
|
|
|
|
|
toast.success(`"${selectedColumn.displayName || selectedColumn.columnName}" 필드가 추가되었습니다.`);
|
|
|
|
|
|
setEditingField(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
|
|
|
|
필드로 추가
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 조인 설정 */}
|
|
|
|
|
|
<div className="space-y-2 pt-2 border-t border-orange-200">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
|
<Badge variant="outline" className={`h-4 px-1 text-[8px] ${hasJoinSetting ? "bg-orange-200 text-orange-700 border-orange-300" : "bg-gray-100 text-gray-600 border-gray-300"}`}>
|
|
|
|
|
|
조인
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
<span className={`text-[10px] font-medium ${hasJoinSetting ? "text-orange-700" : "text-gray-600"}`}>
|
|
|
|
|
|
{editingJoin && editingJoin.columnName === (selectedColumn?.columnName || editingField) ? "연결 편집" : (hasJoinSetting ? "연결 정보" : "연결 설정")}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{editingJoin && editingJoin.columnName === (selectedColumn?.columnName || editingField) ? (
|
|
|
|
|
|
<div className="flex gap-1">
|
|
|
|
|
|
<Button variant="ghost" size="sm" className="h-5 px-1.5 text-[10px] text-gray-500 hover:text-gray-700" onClick={() => setEditingJoin(null)}>
|
|
|
|
|
|
취소
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button size="sm" className="h-5 px-1.5 text-[10px] bg-orange-500 hover:bg-orange-600 text-white" onClick={handleSaveJoinSetting} disabled={!editingJoin.referenceTable || !editingJoin.referenceColumn || savingJoinSetting}>
|
|
|
|
|
|
{savingJoinSetting ? "..." : "저장"}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className={`h-5 px-1 text-[10px] ${hasJoinSetting ? "text-orange-600 hover:text-orange-800" : "text-gray-500 hover:text-gray-700"}`}
|
|
|
|
|
|
onClick={() => startEditingJoin(
|
|
|
|
|
|
selectedColumn?.columnName || editingField,
|
|
|
|
|
|
isJoinKey && joinRef ? joinRef.refTable : (selectedColumn?.referenceTable || ""),
|
|
|
|
|
|
isJoinKey && joinRef ? joinRef.refColumn : (selectedColumn?.referenceColumn || ""),
|
|
|
|
|
|
selectedColumn?.displayColumn || ""
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Settings2 className="h-3 w-3 mr-0.5" />
|
|
|
|
|
|
{hasJoinSetting ? "편집" : "추가"}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{editingJoin && editingJoin.columnName === (selectedColumn?.columnName || editingField) ? (
|
|
|
|
|
|
<JoinSettingEditor
|
|
|
|
|
|
editingJoin={editingJoin}
|
|
|
|
|
|
setEditingJoin={setEditingJoin}
|
|
|
|
|
|
allTables={allTables}
|
|
|
|
|
|
refTableColumns={refTableColumns}
|
|
|
|
|
|
loadingRefColumns={loadingRefColumns}
|
|
|
|
|
|
savingJoinSetting={savingJoinSetting}
|
|
|
|
|
|
loadRefTableColumns={loadRefTableColumns}
|
|
|
|
|
|
handleSaveJoinSetting={handleSaveJoinSetting}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : hasJoinSetting ? (
|
|
|
|
|
|
<div className="space-y-1.5 text-xs">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span className="text-muted-foreground">대상 테이블: </span>
|
|
|
|
|
|
<span className="font-mono text-orange-800">{isJoinKey && joinRef ? joinRef.refTable : selectedColumn?.referenceTable}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span className="text-muted-foreground">연결 컬럼: </span>
|
|
|
|
|
|
<span className="font-mono text-orange-800">{isJoinKey && joinRef ? joinRef.refColumn : selectedColumn?.referenceColumn}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="text-[10px] text-muted-foreground">조인 설정이 없습니다.</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 필터 정보 (필터 키인 경우) - 필터 테이블에서만 */}
|
|
|
|
|
|
{!isMain && isFilterKey && filterKeyMapping && (
|
|
|
|
|
|
<div className="space-y-2 pt-2 border-t border-purple-200">
|
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
|
<Badge variant="outline" className="h-4 px-1 text-[8px] bg-purple-200 text-purple-700 border-purple-300">필터</Badge>
|
|
|
|
|
|
<span className="text-[10px] text-purple-700 font-medium">필터링 정보</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1.5 text-xs">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span className="text-muted-foreground">대상 테이블: </span>
|
|
|
|
|
|
<span className="font-mono text-purple-800">{mainTable}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span className="text-muted-foreground">연결 컬럼: </span>
|
|
|
|
|
|
<span className="font-mono text-purple-800">{filterKeyMapping.mainTableColumn}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})() : (
|
|
|
|
|
|
<div className="flex items-center justify-center h-full text-xs text-muted-foreground">
|
|
|
|
|
|
필드를 선택하세요
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-01-09 18:26:37 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<div className="text-xs text-muted-foreground text-center py-2">컬럼 정보 없음</div>
|
2026-01-09 18:26:37 +09:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 조인 설정 편집 컴포넌트 (검색 가능한 Combobox 사용)
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
interface JoinSettingEditorProps {
|
|
|
|
|
|
editingJoin: {
|
|
|
|
|
|
columnName: string;
|
|
|
|
|
|
referenceTable: string;
|
|
|
|
|
|
referenceColumn: string;
|
|
|
|
|
|
displayColumn: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
setEditingJoin: React.Dispatch<React.SetStateAction<{
|
|
|
|
|
|
columnName: string;
|
|
|
|
|
|
referenceTable: string;
|
|
|
|
|
|
referenceColumn: string;
|
|
|
|
|
|
displayColumn: string;
|
|
|
|
|
|
} | null>>;
|
|
|
|
|
|
allTables: TableInfo[];
|
|
|
|
|
|
refTableColumns: ColumnTypeInfo[];
|
|
|
|
|
|
loadingRefColumns: boolean;
|
|
|
|
|
|
savingJoinSetting: boolean;
|
|
|
|
|
|
loadRefTableColumns: (tableName: string) => void;
|
|
|
|
|
|
handleSaveJoinSetting: () => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function JoinSettingEditor({
|
|
|
|
|
|
editingJoin,
|
|
|
|
|
|
setEditingJoin,
|
|
|
|
|
|
allTables,
|
|
|
|
|
|
refTableColumns,
|
|
|
|
|
|
loadingRefColumns,
|
|
|
|
|
|
savingJoinSetting,
|
|
|
|
|
|
loadRefTableColumns,
|
|
|
|
|
|
handleSaveJoinSetting,
|
|
|
|
|
|
}: JoinSettingEditorProps) {
|
|
|
|
|
|
const [tableSearchOpen, setTableSearchOpen] = useState(false);
|
|
|
|
|
|
const [refColSearchOpen, setRefColSearchOpen] = useState(false);
|
|
|
|
|
|
const [displayColSearchOpen, setDisplayColSearchOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
const selectedTable = allTables.find(t => t.tableName === editingJoin.referenceTable);
|
|
|
|
|
|
const selectedRefCol = refTableColumns.find(c => c.columnName === editingJoin.referenceColumn);
|
|
|
|
|
|
const selectedDisplayCol = refTableColumns.find(c => c.columnName === editingJoin.displayColumn);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
{/* 대상 테이블 선택 - 검색 가능 Combobox */}
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">대상 테이블</span>
|
|
|
|
|
|
<Popover open={tableSearchOpen} onOpenChange={setTableSearchOpen}>
|
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="w-full justify-between h-7 text-xs font-normal"
|
|
|
|
|
|
>
|
|
|
|
|
|
{selectedTable?.displayName || editingJoin.referenceTable || "테이블 선택"}
|
|
|
|
|
|
<ChevronsUpDown className="h-3 w-3 opacity-50" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
|
<PopoverContent className="p-0 w-56" align="start">
|
|
|
|
|
|
<Command>
|
|
|
|
|
|
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
|
|
|
|
|
<CommandList>
|
|
|
|
|
|
<CommandEmpty className="text-xs py-2">테이블을 찾을 수 없습니다.</CommandEmpty>
|
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
|
{allTables.map(t => (
|
|
|
|
|
|
<CommandItem
|
|
|
|
|
|
key={t.tableName}
|
|
|
|
|
|
value={t.displayName || t.tableName}
|
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
|
setEditingJoin({ ...editingJoin, referenceTable: t.tableName, referenceColumn: "", displayColumn: "" });
|
|
|
|
|
|
loadRefTableColumns(t.tableName);
|
|
|
|
|
|
setTableSearchOpen(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"mr-2 h-3 w-3",
|
|
|
|
|
|
editingJoin.referenceTable === t.tableName ? "opacity-100" : "opacity-0"
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{t.displayName || t.tableName}
|
|
|
|
|
|
</CommandItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
|
</CommandList>
|
|
|
|
|
|
</Command>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 연결 컬럼 선택 - 검색 가능 Combobox */}
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">연결 컬럼 (PK)</span>
|
|
|
|
|
|
<Popover open={refColSearchOpen} onOpenChange={setRefColSearchOpen}>
|
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="w-full justify-between h-7 text-xs font-normal"
|
|
|
|
|
|
disabled={!editingJoin.referenceTable || loadingRefColumns}
|
|
|
|
|
|
>
|
|
|
|
|
|
{loadingRefColumns ? "로딩중..." : (selectedRefCol?.displayName || editingJoin.referenceColumn || "컬럼 선택")}
|
|
|
|
|
|
<ChevronsUpDown className="h-3 w-3 opacity-50" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
|
<PopoverContent className="p-0 w-56" align="start">
|
|
|
|
|
|
<Command>
|
|
|
|
|
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
|
|
|
|
|
<CommandList>
|
|
|
|
|
|
<CommandEmpty className="text-xs py-2">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
|
{refTableColumns.map(c => (
|
|
|
|
|
|
<CommandItem
|
|
|
|
|
|
key={c.columnName}
|
|
|
|
|
|
value={c.displayName || c.columnName}
|
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
|
setEditingJoin({ ...editingJoin, referenceColumn: c.columnName });
|
|
|
|
|
|
setRefColSearchOpen(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"mr-2 h-3 w-3",
|
|
|
|
|
|
editingJoin.referenceColumn === c.columnName ? "opacity-100" : "opacity-0"
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{c.displayName || c.columnName}
|
|
|
|
|
|
</CommandItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
|
</CommandList>
|
|
|
|
|
|
</Command>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 표시 컬럼 선택 - 검색 가능 Combobox */}
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">표시 컬럼</span>
|
|
|
|
|
|
<Popover open={displayColSearchOpen} onOpenChange={setDisplayColSearchOpen}>
|
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="w-full justify-between h-7 text-xs font-normal"
|
|
|
|
|
|
disabled={!editingJoin.referenceTable || loadingRefColumns}
|
|
|
|
|
|
>
|
|
|
|
|
|
{selectedDisplayCol?.displayName || editingJoin.displayColumn || "컬럼 선택"}
|
|
|
|
|
|
<ChevronsUpDown className="h-3 w-3 opacity-50" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
|
<PopoverContent className="p-0 w-56" align="start">
|
|
|
|
|
|
<Command>
|
|
|
|
|
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
|
|
|
|
|
<CommandList>
|
|
|
|
|
|
<CommandEmpty className="text-xs py-2">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
|
{refTableColumns.map(c => (
|
|
|
|
|
|
<CommandItem
|
|
|
|
|
|
key={c.columnName}
|
|
|
|
|
|
value={c.displayName || c.columnName}
|
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
|
setEditingJoin({ ...editingJoin, displayColumn: c.columnName });
|
|
|
|
|
|
setDisplayColSearchOpen(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"mr-2 h-3 w-3",
|
|
|
|
|
|
editingJoin.displayColumn === c.columnName ? "opacity-100" : "opacity-0"
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{c.displayName || c.columnName}
|
|
|
|
|
|
</CommandItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
|
</CommandList>
|
|
|
|
|
|
</Command>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 17:03:00 +09:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 탭 1: 화면 개요
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
interface OverviewTabProps {
|
|
|
|
|
|
screenId: number;
|
|
|
|
|
|
screenName: string;
|
|
|
|
|
|
mainTable?: string;
|
|
|
|
|
|
mainTableLabel?: string;
|
|
|
|
|
|
filterTables: FilterTableInfo[];
|
|
|
|
|
|
fieldMappings: FieldMappingInfo[];
|
|
|
|
|
|
componentCount: number;
|
|
|
|
|
|
dataFlows: DataFlow[];
|
2026-01-09 18:26:37 +09:00
|
|
|
|
layoutItems: LayoutItem[]; // 컴포넌트 컬럼 정보 추가
|
2026-01-09 17:03:00 +09:00
|
|
|
|
loading: boolean;
|
2026-01-13 13:28:50 +09:00
|
|
|
|
onRefresh?: () => void; // 컬럼 변경 후 새로고침 콜백
|
2026-01-14 14:35:27 +09:00
|
|
|
|
onOpenTableSetting?: (tableName: string, tableLabel?: string) => void; // 테이블 설정 모달 열기
|
2026-01-09 17:03:00 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function OverviewTab({
|
|
|
|
|
|
screenId,
|
|
|
|
|
|
screenName,
|
|
|
|
|
|
mainTable,
|
|
|
|
|
|
mainTableLabel,
|
|
|
|
|
|
filterTables,
|
|
|
|
|
|
fieldMappings,
|
|
|
|
|
|
componentCount,
|
|
|
|
|
|
dataFlows,
|
2026-01-09 18:26:37 +09:00
|
|
|
|
layoutItems,
|
2026-01-09 17:03:00 +09:00
|
|
|
|
loading,
|
2026-01-13 13:28:50 +09:00
|
|
|
|
onRefresh,
|
2026-01-14 14:35:27 +09:00
|
|
|
|
onOpenTableSetting,
|
2026-01-09 17:03:00 +09:00
|
|
|
|
}: OverviewTabProps) {
|
2026-01-13 13:28:50 +09:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
let updatedComponents = currentLayout.components.map((comp: any) => {
|
2026-01-13 13:28:50 +09:00
|
|
|
|
// 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;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// 폼 화면용 필드 추가/제거 처리 (개별 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
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-01-09 18:26:37 +09:00
|
|
|
|
// 통계 계산 (layoutItems의 컬럼 수도 포함)
|
2026-01-09 17:03:00 +09:00
|
|
|
|
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
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// layoutItems에서 사용하는 컬럼 수 계산 (usedColumns + bindField)
|
2026-01-09 18:26:37 +09:00
|
|
|
|
const layoutColumnsSet = new Set<string>();
|
|
|
|
|
|
layoutItems.forEach((item) => {
|
|
|
|
|
|
if (item.usedColumns) {
|
|
|
|
|
|
item.usedColumns.forEach((col) => layoutColumnsSet.add(col));
|
|
|
|
|
|
}
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// bindField도 포함 (인풋 필드 등)
|
|
|
|
|
|
if (item.bindField) {
|
|
|
|
|
|
layoutColumnsSet.add(item.bindField);
|
|
|
|
|
|
}
|
2026-01-09 18:26:37 +09:00
|
|
|
|
});
|
|
|
|
|
|
const layoutColumnCount = layoutColumnsSet.size;
|
|
|
|
|
|
|
2026-01-09 17:03:00 +09:00
|
|
|
|
return {
|
|
|
|
|
|
tableCount: 1 + filterTables.length, // 메인 + 필터
|
2026-01-09 18:26:37 +09:00
|
|
|
|
fieldCount: layoutColumnCount > 0 ? layoutColumnCount : fieldMappings.length,
|
2026-01-09 17:03:00 +09:00
|
|
|
|
joinCount: totalJoins,
|
|
|
|
|
|
filterCount: totalFilters,
|
|
|
|
|
|
flowCount: dataFlows.length,
|
2026-01-14 14:35:27 +09:00
|
|
|
|
usedFields: layoutColumnsSet, // 사용 중인 컬럼 Set
|
2026-01-09 17:03:00 +09:00
|
|
|
|
};
|
2026-01-09 18:26:37 +09:00
|
|
|
|
}, [filterTables, fieldMappings, dataFlows, layoutItems]);
|
2026-01-09 17:03:00 +09:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
{/* 기본 정보 카드 */}
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
|
|
|
|
|
|
<div className="rounded-lg border bg-blue-50 p-4">
|
|
|
|
|
|
<div className="text-2xl font-bold text-blue-600">{stats.tableCount}</div>
|
|
|
|
|
|
<div className="text-xs text-blue-700">연결된 테이블</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="rounded-lg border bg-purple-50 p-4">
|
|
|
|
|
|
<div className="text-2xl font-bold text-purple-600">{stats.fieldCount}</div>
|
|
|
|
|
|
<div className="text-xs text-purple-700">필드 매핑</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="rounded-lg border bg-orange-50 p-4">
|
|
|
|
|
|
<div className="text-2xl font-bold text-orange-600">{stats.joinCount}</div>
|
|
|
|
|
|
<div className="text-xs text-orange-700">조인 설정</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="rounded-lg border bg-green-50 p-4">
|
|
|
|
|
|
<div className="text-2xl font-bold text-green-600">{stats.filterCount}</div>
|
|
|
|
|
|
<div className="text-xs text-green-700">필터 컬럼</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="rounded-lg border bg-pink-50 p-4">
|
|
|
|
|
|
<div className="text-2xl font-bold text-pink-600">{stats.flowCount}</div>
|
|
|
|
|
|
<div className="text-xs text-pink-700">데이터 흐름</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
{/* 메인 테이블 (아코디언 형식) */}
|
2026-01-09 17:03:00 +09:00
|
|
|
|
<div className="space-y-2">
|
2026-01-14 14:35:27 +09:00
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
|
|
|
|
|
<Database className="h-4 w-4 text-blue-500" />
|
|
|
|
|
|
메인 테이블
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
{mainTable && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="h-7 gap-1 text-xs"
|
|
|
|
|
|
onClick={() => onOpenTableSetting?.(mainTable, mainTableLabel)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Settings2 className="h-3 w-3" />
|
|
|
|
|
|
테이블 설정
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
{mainTable ? (
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<TableColumnAccordion
|
|
|
|
|
|
tableName={mainTable}
|
|
|
|
|
|
tableLabel={mainTableLabel}
|
|
|
|
|
|
tableType="main"
|
2026-01-14 14:35:27 +09:00
|
|
|
|
usedFields={stats.usedFields}
|
2026-01-13 13:28:50 +09:00
|
|
|
|
columnMappings={
|
|
|
|
|
|
// layoutItems에서 컬럼 매핑 정보 추출 (y 좌표 순서대로)
|
|
|
|
|
|
layoutItems
|
|
|
|
|
|
.slice()
|
|
|
|
|
|
.sort((a, b) => a.y - b.y) // 화면 순서대로 정렬
|
2026-01-14 14:35:27 +09:00
|
|
|
|
.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 => ({
|
2026-01-13 13:28:50 +09:00
|
|
|
|
columnName: col,
|
2026-01-14 14:35:27 +09:00
|
|
|
|
fieldLabel: col,
|
|
|
|
|
|
order: idx * 100 + cols.indexOf(col),
|
|
|
|
|
|
}));
|
|
|
|
|
|
})
|
2026-01-13 13:28:50 +09:00
|
|
|
|
// 중복 제거 (첫 번째 매핑만 유지)
|
|
|
|
|
|
.filter((mapping, idx, arr) =>
|
|
|
|
|
|
arr.findIndex(m => m.columnName.toLowerCase() === mapping.columnName.toLowerCase()) === idx
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
onColumnChange={handleColumnChange}
|
|
|
|
|
|
onColumnReorder={(newOrder) => handleColumnReorder("main", newOrder)}
|
|
|
|
|
|
onJoinSettingSaved={onRefresh}
|
|
|
|
|
|
/>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
) : (
|
|
|
|
|
|
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
|
|
|
|
|
|
메인 테이블이 설정되지 않았습니다.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-09 18:26:37 +09:00
|
|
|
|
{/* 연결된 필터 테이블 (아코디언 형식) */}
|
2026-01-09 17:03:00 +09:00
|
|
|
|
<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">
|
2026-01-13 13:28:50 +09:00
|
|
|
|
{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}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-01-09 17:03:00 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
|
2026-01-09 18:26:37 +09:00
|
|
|
|
연결된 필터 테이블이 없습니다.
|
2026-01-09 17:03:00 +09:00
|
|
|
|
</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[];
|
2026-01-09 18:26:37 +09:00
|
|
|
|
layoutItems: LayoutItem[];
|
2026-01-09 17:03:00 +09:00
|
|
|
|
loading: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function FieldMappingTab({
|
|
|
|
|
|
screenId,
|
|
|
|
|
|
mainTable,
|
|
|
|
|
|
fieldMappings,
|
2026-01-09 18:26:37 +09:00
|
|
|
|
layoutItems,
|
2026-01-09 17:03:00 +09:00
|
|
|
|
loading,
|
|
|
|
|
|
}: FieldMappingTabProps) {
|
2026-01-13 13:28:50 +09:00
|
|
|
|
// 편집 모드 상태
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-01-09 18:26:37 +09:00
|
|
|
|
// 화면 컴포넌트에서 사용하는 컬럼 정보 추출
|
|
|
|
|
|
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]);
|
2026-01-13 13:28:50 +09:00
|
|
|
|
|
|
|
|
|
|
// 컬럼명 → 표시명 매핑 (테이블 컬럼에서 추출)
|
|
|
|
|
|
const columnDisplayMap = useMemo(() => {
|
|
|
|
|
|
const map: Record<string, string> = {};
|
|
|
|
|
|
tableColumns.forEach((tc) => {
|
|
|
|
|
|
map[tc.columnName] = tc.displayName || tc.columnName;
|
|
|
|
|
|
});
|
|
|
|
|
|
return map;
|
|
|
|
|
|
}, [tableColumns]);
|
2026-01-09 18:26:37 +09:00
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 타입별 그룹핑 (기존 fieldMappings용)
|
2026-01-09 17:03:00 +09:00
|
|
|
|
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 (
|
2026-01-09 18:26:37 +09:00
|
|
|
|
<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">
|
2026-01-13 13:28:50 +09:00
|
|
|
|
{isEditMode
|
|
|
|
|
|
? "컬럼을 클릭하여 매핑을 변경할 수 있습니다."
|
|
|
|
|
|
: "각 컴포넌트에서 사용하는 테이블 컬럼을 확인합니다."}
|
2026-01-09 18:26:37 +09:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<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>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-09 18:26:37 +09:00
|
|
|
|
{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}
|
2026-01-13 13:28:50 +09:00
|
|
|
|
className="rounded-lg border bg-white overflow-hidden"
|
2026-01-09 17:03:00 +09:00
|
|
|
|
>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
{/* 컴포넌트 헤더 */}
|
|
|
|
|
|
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-b">
|
2026-01-09 18:26:37 +09:00
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Table2 className="h-4 w-4 text-blue-500" />
|
|
|
|
|
|
<span className="text-sm font-medium">
|
|
|
|
|
|
{comp.componentLabel || comp.componentKind}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Badge variant="outline" className="text-[10px]">
|
|
|
|
|
|
{comp.componentKind}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Badge variant="outline" className="text-xs">
|
2026-01-13 13:28:50 +09:00
|
|
|
|
{comp.columns.length}개 필드
|
2026-01-09 18:26:37 +09:00
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 필드 → 컬럼 매핑 테이블 */}
|
|
|
|
|
|
<div className="divide-y">
|
|
|
|
|
|
{/* 테이블 헤더 */}
|
|
|
|
|
|
<div className="grid grid-cols-[1fr_28px_1fr] gap-1 px-3 py-1.5 bg-gray-100 text-[10px] font-medium text-gray-500 uppercase">
|
|
|
|
|
|
<span>필드명 (화면 표시)</span>
|
|
|
|
|
|
<span></span>
|
|
|
|
|
|
<span>컬럼명 (데이터베이스)</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 매핑 행들 */}
|
2026-01-09 18:26:37 +09:00
|
|
|
|
{comp.columns.map((col, cIdx) => {
|
|
|
|
|
|
const isJoinColumn = comp.joinColumns.includes(col);
|
2026-01-13 13:28:50 +09:00
|
|
|
|
const displayName = columnDisplayMap[col] || col;
|
|
|
|
|
|
const isEditing = editingColumn?.componentIdx === idx && editingColumn?.columnIdx === cIdx;
|
|
|
|
|
|
|
2026-01-09 18:26:37 +09:00
|
|
|
|
return (
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<div
|
2026-01-09 18:26:37 +09:00
|
|
|
|
key={cIdx}
|
|
|
|
|
|
className={cn(
|
2026-01-13 13:28:50 +09:00
|
|
|
|
"grid grid-cols-[1fr_28px_1fr] gap-1 px-3 py-1.5 items-center text-xs",
|
|
|
|
|
|
isJoinColumn && "bg-orange-50"
|
2026-01-09 18:26:37 +09:00
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
{/* 필드명 (화면 표시) */}
|
|
|
|
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
|
|
|
|
<span className="font-medium text-gray-900 truncate">
|
|
|
|
|
|
{displayName}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{isJoinColumn && (
|
|
|
|
|
|
<Badge variant="outline" className="text-[9px] bg-orange-100 text-orange-700 px-1 py-0 flex-shrink-0">
|
|
|
|
|
|
조인
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 화살표 */}
|
|
|
|
|
|
<div className="flex justify-center">
|
|
|
|
|
|
<ArrowRight className="h-3 w-3 text-gray-400" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 컬럼명 (데이터베이스) */}
|
|
|
|
|
|
{isEditMode ? (
|
|
|
|
|
|
<Popover
|
|
|
|
|
|
open={isEditing && editPopoverOpen}
|
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
|
if (open) {
|
|
|
|
|
|
setEditingColumn({ componentIdx: idx, columnIdx: cIdx, currentColumn: col });
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setEditingColumn(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
setEditPopoverOpen(open);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex items-center gap-1 px-2 py-0.5 rounded text-left min-w-0",
|
|
|
|
|
|
"hover:bg-blue-50 border border-transparent hover:border-blue-300",
|
|
|
|
|
|
"transition-colors cursor-pointer group"
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<code className="text-blue-700 font-mono text-[11px] truncate">{col}</code>
|
|
|
|
|
|
<Pencil className="h-3 w-3 text-gray-400 group-hover:text-blue-500 flex-shrink-0" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
|
<PopoverContent className="w-64 p-0" align="start">
|
|
|
|
|
|
<Command>
|
|
|
|
|
|
<CommandInput
|
|
|
|
|
|
placeholder="컬럼 검색..."
|
|
|
|
|
|
className="text-xs"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<CommandList>
|
|
|
|
|
|
<CommandEmpty className="text-xs py-2 text-center">
|
|
|
|
|
|
컬럼을 찾을 수 없습니다.
|
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
|
<CommandGroup heading="테이블 컬럼">
|
|
|
|
|
|
{loadingTableColumns ? (
|
|
|
|
|
|
<div className="flex items-center justify-center py-4">
|
|
|
|
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
tableColumns.map((tableCol) => (
|
|
|
|
|
|
<CommandItem
|
|
|
|
|
|
key={tableCol.columnName}
|
|
|
|
|
|
value={tableCol.columnName}
|
|
|
|
|
|
onSelect={(value) => {
|
|
|
|
|
|
toast.info(`컬럼 변경: ${col} → ${value}`, {
|
|
|
|
|
|
description: "저장 기능은 아직 구현 중입니다."
|
|
|
|
|
|
});
|
|
|
|
|
|
setEditPopoverOpen(false);
|
|
|
|
|
|
setEditingColumn(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
|
<span className={cn(
|
|
|
|
|
|
"font-medium",
|
|
|
|
|
|
tableCol.columnName === col && "text-blue-600"
|
|
|
|
|
|
)}>
|
|
|
|
|
|
{tableCol.displayName || tableCol.columnName}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{tableCol.displayName && tableCol.displayName !== tableCol.columnName && (
|
|
|
|
|
|
<span className="text-[10px] text-muted-foreground font-mono">
|
|
|
|
|
|
{tableCol.columnName}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{tableCol.columnName === col && (
|
|
|
|
|
|
<Check className="ml-auto h-3 w-3 text-blue-600" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CommandItem>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
|
</CommandList>
|
|
|
|
|
|
</Command>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<code className="text-blue-700 font-mono text-[11px] truncate">{col}</code>
|
2026-01-09 18:26:37 +09:00
|
|
|
|
)}
|
2026-01-13 13:28:50 +09:00
|
|
|
|
</div>
|
2026-01-09 18:26:37 +09:00
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-01-09 18:26:37 +09:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 서브 테이블 연결 관계 (기존 fieldMappings) */}
|
|
|
|
|
|
{fieldMappings.length > 0 && (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h3 className="text-sm font-semibold">서브 테이블 연결 관계</h3>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
|
메인 테이블과 서브 테이블 간의 필드 연결 관계입니다.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Badge variant="outline" className="text-xs">
|
|
|
|
|
|
총 {fieldMappings.length}개 연결
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="rounded-lg border">
|
|
|
|
|
|
<Table>
|
|
|
|
|
|
<TableHeader>
|
|
|
|
|
|
<TableRow>
|
|
|
|
|
|
<TableHead className="w-[50px] text-xs">#</TableHead>
|
|
|
|
|
|
<TableHead className="text-xs">메인 테이블 컬럼</TableHead>
|
|
|
|
|
|
<TableHead className="w-[60px] text-center text-xs"></TableHead>
|
|
|
|
|
|
<TableHead className="text-xs">서브 테이블</TableHead>
|
|
|
|
|
|
<TableHead className="text-xs">서브 테이블 컬럼</TableHead>
|
|
|
|
|
|
<TableHead className="text-xs">연결 타입</TableHead>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableHeader>
|
|
|
|
|
|
<TableBody>
|
|
|
|
|
|
{fieldMappings.map((mapping, idx) => (
|
|
|
|
|
|
<TableRow key={idx}>
|
|
|
|
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
|
|
|
|
{idx + 1}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell className="text-xs font-medium">
|
|
|
|
|
|
<Badge variant="outline" className="bg-blue-50 text-blue-700">
|
|
|
|
|
|
{mainTable}.{mapping.targetField}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell className="text-center">
|
|
|
|
|
|
<ArrowRight className="mx-auto h-3 w-3 text-gray-400" />
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell className="text-xs">
|
|
|
|
|
|
<Badge variant="outline" className="bg-purple-50 text-purple-700">
|
|
|
|
|
|
{mapping.sourceTable || "-"}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell className="text-xs">
|
|
|
|
|
|
<Badge variant="outline" className="bg-green-50 text-green-700">
|
|
|
|
|
|
{mapping.sourceField}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
|
|
|
|
{mapping.componentType || "-"}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</TableBody>
|
|
|
|
|
|
</Table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 컴포넌트 타입별 요약 */}
|
|
|
|
|
|
{componentTypes.length > 0 && (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<h4 className="text-xs font-medium text-muted-foreground">연결 타입별 분류</h4>
|
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
|
{componentTypes.map((type) => (
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
key={type}
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
className="gap-1 bg-gray-50"
|
|
|
|
|
|
>
|
|
|
|
|
|
{type}
|
|
|
|
|
|
<span className="rounded-full bg-gray-200 px-1.5 text-[10px]">
|
|
|
|
|
|
{groupedMappings[type].length}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-01-09 17:03:00 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 탭 3: 데이터 흐름
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
interface DataFlowTabProps {
|
|
|
|
|
|
screenId: number;
|
|
|
|
|
|
groupId?: number;
|
|
|
|
|
|
dataFlows: DataFlow[];
|
|
|
|
|
|
loading: boolean;
|
|
|
|
|
|
onReload: () => void;
|
|
|
|
|
|
onSaveSuccess?: () => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function DataFlowTab({
|
|
|
|
|
|
screenId,
|
|
|
|
|
|
groupId,
|
|
|
|
|
|
dataFlows,
|
|
|
|
|
|
loading,
|
|
|
|
|
|
onReload,
|
|
|
|
|
|
onSaveSuccess,
|
|
|
|
|
|
}: DataFlowTabProps) {
|
|
|
|
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
|
|
|
|
const [editItem, setEditItem] = useState<DataFlow | null>(null);
|
|
|
|
|
|
const [formData, setFormData] = useState({
|
|
|
|
|
|
target_screen_id: "",
|
|
|
|
|
|
action_type: "navigate",
|
|
|
|
|
|
data_mapping: "",
|
|
|
|
|
|
flow_type: "forward",
|
|
|
|
|
|
description: "",
|
|
|
|
|
|
is_active: "Y",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 폼 초기화
|
|
|
|
|
|
const resetForm = () => {
|
|
|
|
|
|
setFormData({
|
|
|
|
|
|
target_screen_id: "",
|
|
|
|
|
|
action_type: "navigate",
|
|
|
|
|
|
data_mapping: "",
|
|
|
|
|
|
flow_type: "forward",
|
|
|
|
|
|
description: "",
|
|
|
|
|
|
is_active: "Y",
|
|
|
|
|
|
});
|
|
|
|
|
|
setEditItem(null);
|
|
|
|
|
|
setIsEditing(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 수정 모드
|
|
|
|
|
|
const handleEdit = (item: DataFlow) => {
|
|
|
|
|
|
setEditItem(item);
|
|
|
|
|
|
setFormData({
|
|
|
|
|
|
target_screen_id: String(item.target_screen_id),
|
|
|
|
|
|
action_type: item.action_type,
|
|
|
|
|
|
data_mapping: item.data_mapping || "",
|
|
|
|
|
|
flow_type: item.flow_type,
|
|
|
|
|
|
description: item.description || "",
|
|
|
|
|
|
is_active: item.is_active,
|
|
|
|
|
|
});
|
|
|
|
|
|
setIsEditing(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 저장
|
|
|
|
|
|
const handleSave = async () => {
|
|
|
|
|
|
if (!formData.target_screen_id) {
|
|
|
|
|
|
toast.error("대상 화면을 선택해주세요.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = {
|
|
|
|
|
|
source_screen_id: screenId,
|
|
|
|
|
|
target_screen_id: parseInt(formData.target_screen_id),
|
|
|
|
|
|
action_type: formData.action_type,
|
|
|
|
|
|
data_mapping: formData.data_mapping || null,
|
|
|
|
|
|
flow_type: formData.flow_type,
|
|
|
|
|
|
description: formData.description || null,
|
|
|
|
|
|
is_active: formData.is_active,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let response;
|
|
|
|
|
|
if (editItem) {
|
|
|
|
|
|
response = await updateDataFlow(editItem.id, payload);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
response = await createDataFlow(payload);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (response.success) {
|
|
|
|
|
|
toast.success(editItem ? "데이터 흐름이 수정되었습니다." : "데이터 흐름이 추가되었습니다.");
|
|
|
|
|
|
resetForm();
|
|
|
|
|
|
onReload();
|
|
|
|
|
|
onSaveSuccess?.();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast.error(response.message || "저장에 실패했습니다.");
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("저장 오류:", error);
|
|
|
|
|
|
toast.error("저장 중 오류가 발생했습니다.");
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 삭제
|
|
|
|
|
|
const handleDelete = async (id: number) => {
|
|
|
|
|
|
if (!confirm("정말로 삭제하시겠습니까?")) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await deleteDataFlow(id);
|
|
|
|
|
|
if (response.success) {
|
|
|
|
|
|
toast.success("데이터 흐름이 삭제되었습니다.");
|
|
|
|
|
|
onReload();
|
|
|
|
|
|
onSaveSuccess?.();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast.error(response.message || "삭제에 실패했습니다.");
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("삭제 오류:", error);
|
|
|
|
|
|
toast.error("삭제 중 오류가 발생했습니다.");
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex h-64 items-center justify-center">
|
|
|
|
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
{/* 입력 폼 */}
|
|
|
|
|
|
<div className="space-y-3 rounded-lg bg-muted/50 p-4">
|
|
|
|
|
|
<div className="text-sm font-medium">
|
|
|
|
|
|
{isEditing ? "데이터 흐름 수정" : "새 데이터 흐름 추가"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs">대상 화면 ID *</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
value={formData.target_screen_id}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setFormData({ ...formData, target_screen_id: e.target.value })
|
|
|
|
|
|
}
|
|
|
|
|
|
placeholder="화면 ID"
|
|
|
|
|
|
className="h-8 text-xs"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs">액션 타입</Label>
|
|
|
|
|
|
<SearchableSelect
|
|
|
|
|
|
value={formData.action_type}
|
|
|
|
|
|
onValueChange={(v) => setFormData({ ...formData, action_type: v })}
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "navigate", label: "화면 이동" },
|
|
|
|
|
|
{ value: "modal", label: "모달 열기" },
|
|
|
|
|
|
{ value: "callback", label: "콜백" },
|
|
|
|
|
|
{ value: "refresh", label: "새로고침" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
placeholder="액션 선택"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs">흐름 타입</Label>
|
|
|
|
|
|
<SearchableSelect
|
|
|
|
|
|
value={formData.flow_type}
|
|
|
|
|
|
onValueChange={(v) => setFormData({ ...formData, flow_type: v })}
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "forward", label: "전달 (Forward)" },
|
|
|
|
|
|
{ value: "return", label: "반환 (Return)" },
|
|
|
|
|
|
{ value: "broadcast", label: "브로드캐스트" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
placeholder="흐름 선택"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs">설명</Label>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
value={formData.description}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setFormData({ ...formData, description: e.target.value })
|
|
|
|
|
|
}
|
|
|
|
|
|
placeholder="데이터 흐름에 대한 설명"
|
|
|
|
|
|
className="h-16 resize-none text-xs"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex justify-end gap-2">
|
|
|
|
|
|
{isEditing && (
|
|
|
|
|
|
<Button variant="outline" size="sm" onClick={resetForm}>
|
|
|
|
|
|
취소
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Button size="sm" onClick={handleSave} className="gap-1">
|
|
|
|
|
|
<Save className="h-4 w-4" />
|
|
|
|
|
|
{isEditing ? "수정" : "추가"}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 목록 */}
|
|
|
|
|
|
<div className="rounded-lg border">
|
|
|
|
|
|
<Table>
|
|
|
|
|
|
<TableHeader>
|
|
|
|
|
|
<TableRow>
|
|
|
|
|
|
<TableHead className="text-xs">대상 화면</TableHead>
|
|
|
|
|
|
<TableHead className="text-xs">액션</TableHead>
|
|
|
|
|
|
<TableHead className="text-xs">흐름 타입</TableHead>
|
|
|
|
|
|
<TableHead className="text-xs">설명</TableHead>
|
|
|
|
|
|
<TableHead className="w-[100px] text-xs">작업</TableHead>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableHeader>
|
|
|
|
|
|
<TableBody>
|
|
|
|
|
|
{dataFlows.length === 0 ? (
|
|
|
|
|
|
<TableRow>
|
|
|
|
|
|
<TableCell
|
|
|
|
|
|
colSpan={5}
|
|
|
|
|
|
className="py-8 text-center text-sm text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
등록된 데이터 흐름이 없습니다.
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
dataFlows.map((flow) => (
|
|
|
|
|
|
<TableRow key={flow.id}>
|
|
|
|
|
|
<TableCell className="text-xs font-medium">
|
|
|
|
|
|
화면 {flow.target_screen_id}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell className="text-xs">
|
|
|
|
|
|
<Badge variant="outline" className="text-xs">
|
|
|
|
|
|
{flow.action_type}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell className="text-xs">
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"text-xs",
|
|
|
|
|
|
flow.flow_type === "forward" && "bg-blue-50 text-blue-700",
|
|
|
|
|
|
flow.flow_type === "return" && "bg-green-50 text-green-700",
|
|
|
|
|
|
flow.flow_type === "broadcast" && "bg-purple-50 text-purple-700"
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{flow.flow_type}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
|
|
|
|
{flow.description || "-"}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
<div className="flex gap-1">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
className="h-7 w-7"
|
|
|
|
|
|
onClick={() => handleEdit(flow)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Pencil className="h-3 w-3" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
className="h-7 w-7 text-destructive"
|
|
|
|
|
|
onClick={() => handleDelete(flow.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="h-3 w-3" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</TableBody>
|
|
|
|
|
|
</Table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 탭: 제어 관리
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
interface ButtonControlInfo {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
actionType: string;
|
|
|
|
|
|
targetTable?: string;
|
|
|
|
|
|
operations?: string[];
|
|
|
|
|
|
confirmMessage?: string;
|
2026-01-14 14:35:27 +09:00
|
|
|
|
confirmationEnabled?: boolean;
|
|
|
|
|
|
// 버튼 스타일
|
|
|
|
|
|
backgroundColor?: string;
|
|
|
|
|
|
textColor?: string;
|
|
|
|
|
|
// 모달/네비게이션 관련
|
|
|
|
|
|
modalScreenId?: number;
|
|
|
|
|
|
navigateScreenId?: number;
|
|
|
|
|
|
// 데이터 흐름 제어
|
2026-01-13 13:28:50 +09:00
|
|
|
|
hasDataflowControl?: boolean;
|
|
|
|
|
|
dataflowControlMode?: string;
|
2026-01-14 14:35:27 +09:00
|
|
|
|
flowTiming?: "before" | "after";
|
2026-01-13 13:28:50 +09:00
|
|
|
|
linkedExternalCall?: {
|
|
|
|
|
|
id: number;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
};
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// 다중 플로우 지원
|
|
|
|
|
|
linkedFlows?: {
|
|
|
|
|
|
id: number;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
timing?: "before" | "after";
|
|
|
|
|
|
}[];
|
|
|
|
|
|
// 레거시 호환 (단일 플로우)
|
2026-01-13 13:28:50 +09:00
|
|
|
|
linkedFlow?: {
|
|
|
|
|
|
id: number;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface ControlManagementTabProps {
|
|
|
|
|
|
screenId: number;
|
2026-01-14 14:35:27 +09:00
|
|
|
|
groupId?: number;
|
2026-01-13 13:28:50 +09:00
|
|
|
|
layoutItems: LayoutItem[];
|
|
|
|
|
|
loading: boolean;
|
|
|
|
|
|
onRefresh: () => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ControlManagementTab({
|
|
|
|
|
|
screenId,
|
2026-01-14 14:35:27 +09:00
|
|
|
|
groupId,
|
2026-01-13 13:28:50 +09:00
|
|
|
|
layoutItems,
|
|
|
|
|
|
loading: parentLoading,
|
|
|
|
|
|
onRefresh,
|
|
|
|
|
|
}: ControlManagementTabProps) {
|
|
|
|
|
|
const [buttonControls, setButtonControls] = useState<ButtonControlInfo[]>([]);
|
2026-01-14 14:35:27 +09:00
|
|
|
|
const [flows, setFlows] = useState<NodeFlow[]>([]);
|
2026-01-13 13:28:50 +09:00
|
|
|
|
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>>({});
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// 화면 목록 조회 (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");
|
|
|
|
|
|
};
|
2026-01-13 13:28:50 +09:00
|
|
|
|
|
|
|
|
|
|
// 데이터 로드
|
|
|
|
|
|
const loadData = useCallback(async () => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 1. 화면 레이아웃에서 버튼 정보 추출
|
|
|
|
|
|
const layoutResponse = await screenApi.getLayout(screenId);
|
|
|
|
|
|
|
|
|
|
|
|
if (layoutResponse?.components) {
|
|
|
|
|
|
const buttons: ButtonControlInfo[] = [];
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// 컴포넌트에서 버튼 추출 (화면 디자이너 구조 기준)
|
2026-01-13 13:28:50 +09:00
|
|
|
|
const extractButtons = (components: any[], depth = 0) => {
|
|
|
|
|
|
for (const comp of components) {
|
2026-01-14 14:35:27 +09:00
|
|
|
|
const config = comp.componentConfig || {};
|
|
|
|
|
|
|
|
|
|
|
|
// 버튼 컴포넌트 필터링 (화면 디자이너 저장 구조 기준)
|
|
|
|
|
|
// 1. 새 시스템: type="component" && widgetType="button"
|
|
|
|
|
|
// 2. 새 시스템: componentConfig.webType="button"
|
|
|
|
|
|
// 3. 레거시: type="button"
|
2026-01-13 13:28:50 +09:00
|
|
|
|
const isButton =
|
2026-01-14 14:35:27 +09:00
|
|
|
|
comp.widgetType === "button" ||
|
2026-01-13 13:28:50 +09:00
|
|
|
|
comp.webType === "button" ||
|
|
|
|
|
|
comp.type === "button" ||
|
2026-01-14 14:35:27 +09:00
|
|
|
|
config.webType === "button" ||
|
|
|
|
|
|
comp.componentType?.includes("button") ||
|
|
|
|
|
|
comp.componentKind?.includes("button");
|
2026-01-13 13:28:50 +09:00
|
|
|
|
|
|
|
|
|
|
if (isButton) {
|
|
|
|
|
|
const webTypeConfig = comp.webTypeConfig || {};
|
|
|
|
|
|
const action = config.action || {};
|
2026-01-14 14:35:27 +09:00
|
|
|
|
const style = comp.style || {};
|
2026-01-13 13:28:50 +09:00
|
|
|
|
|
|
|
|
|
|
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 || [],
|
2026-01-14 14:35:27 +09:00
|
|
|
|
confirmMessage: action.confirmationMessage || action.confirmMessage || config.confirmMessage,
|
|
|
|
|
|
confirmationEnabled: action.confirmationEnabled ?? (!!action.confirmationMessage || !!action.confirmMessage),
|
|
|
|
|
|
// 버튼 스타일 (webTypeConfig 우선)
|
|
|
|
|
|
backgroundColor: webTypeConfig.backgroundColor || config.backgroundColor || style.backgroundColor,
|
|
|
|
|
|
textColor: webTypeConfig.textColor || config.textColor || style.color || style.labelColor,
|
|
|
|
|
|
// 모달/네비게이션 관련 (화면 디자이너는 targetScreenId 사용)
|
|
|
|
|
|
modalScreenId: action.targetScreenId || action.modalScreenId,
|
|
|
|
|
|
navigateScreenId: action.navigateScreenId || action.targetScreenId,
|
|
|
|
|
|
// 데이터 흐름 제어
|
2026-01-13 13:28:50 +09:00
|
|
|
|
hasDataflowControl: webTypeConfig.enableDataflowControl,
|
|
|
|
|
|
dataflowControlMode: webTypeConfig.dataflowConfig?.controlMode,
|
2026-01-14 14:35:27 +09:00
|
|
|
|
flowTiming: webTypeConfig.dataflowConfig?.flowConfig?.executionTiming,
|
2026-01-13 13:28:50 +09:00
|
|
|
|
linkedExternalCall: undefined, // TODO: 연결 정보 조회
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// 다중 플로우 지원 (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",
|
|
|
|
|
|
}] : []),
|
|
|
|
|
|
// 레거시 호환 (단일 플로우)
|
2026-01-13 13:28:50 +09:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// 2. 플로우 목록 조회 (버튼 연동용) - node_flows 테이블에서 가져옴
|
|
|
|
|
|
try {
|
|
|
|
|
|
const flowList = await getNodeFlows();
|
|
|
|
|
|
console.log("플로우 목록 응답:", flowList);
|
|
|
|
|
|
setFlows(flowList);
|
|
|
|
|
|
} catch (flowError) {
|
|
|
|
|
|
console.error("플로우 목록 조회 실패:", flowError);
|
2026-01-13 13:28:50 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-01-13 13:28:50 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// 그룹 내 화면 목록
|
|
|
|
|
|
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));
|
|
|
|
|
|
}
|
2026-01-13 13:28:50 +09:00
|
|
|
|
}
|
2026-01-14 14:35:27 +09:00
|
|
|
|
|
|
|
|
|
|
// 그룹 내 화면 우선, 전체 화면도 포함
|
|
|
|
|
|
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);
|
2026-01-13 13:28:50 +09:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("제어 관리 데이터 로드 실패:", error);
|
|
|
|
|
|
toast.error("데이터 로드 실패");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
2026-01-14 14:35:27 +09:00
|
|
|
|
}, [screenId, groupId]);
|
2026-01-13 13:28:50 +09:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadData();
|
|
|
|
|
|
}, [loadData]);
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// 플로우 빠른 생성 함수
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
// 버튼 설정 저장
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// 버튼 컴포넌트 업데이트 (화면 디자이너 구조 기준)
|
2026-01-13 13:28:50 +09:00
|
|
|
|
const updateButton = (components: any[]): boolean => {
|
|
|
|
|
|
for (const comp of components) {
|
2026-01-14 14:35:27 +09:00
|
|
|
|
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) {
|
2026-01-13 13:28:50 +09:00
|
|
|
|
// componentConfig 업데이트
|
|
|
|
|
|
if (!comp.componentConfig) comp.componentConfig = {};
|
|
|
|
|
|
if (!comp.componentConfig.action) comp.componentConfig.action = {};
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// 버튼 라벨(텍스트) 업데이트
|
|
|
|
|
|
if (values.label !== undefined) {
|
|
|
|
|
|
comp.componentConfig.text = values.label;
|
|
|
|
|
|
// 레거시 호환: 여러 위치에 저장
|
|
|
|
|
|
comp.label = values.label;
|
|
|
|
|
|
comp.title = values.label;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 버튼 스타일(색상) 업데이트 (webTypeConfig에 저장해야 실제 버튼에 반영됨)
|
|
|
|
|
|
if (!comp.webTypeConfig) comp.webTypeConfig = {};
|
|
|
|
|
|
if (!comp.style) comp.style = {};
|
|
|
|
|
|
if (values.backgroundColor !== undefined) {
|
|
|
|
|
|
comp.webTypeConfig.backgroundColor = values.backgroundColor;
|
|
|
|
|
|
comp.componentConfig.backgroundColor = values.backgroundColor;
|
|
|
|
|
|
comp.style.backgroundColor = values.backgroundColor;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (values.textColor !== undefined) {
|
|
|
|
|
|
comp.webTypeConfig.textColor = values.textColor;
|
|
|
|
|
|
comp.componentConfig.textColor = values.textColor;
|
|
|
|
|
|
comp.style.color = values.textColor;
|
|
|
|
|
|
comp.style.labelColor = values.textColor;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 액션 타입 업데이트
|
|
|
|
|
|
if (values.actionType) {
|
|
|
|
|
|
comp.componentConfig.action.type = values.actionType;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 대상 테이블 업데이트
|
|
|
|
|
|
if (values.targetTable !== undefined) {
|
2026-01-13 13:28:50 +09:00
|
|
|
|
comp.componentConfig.tableName = values.targetTable;
|
|
|
|
|
|
}
|
2026-01-14 14:35:27 +09:00
|
|
|
|
|
|
|
|
|
|
// 확인 다이얼로그 설정 (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;
|
2026-01-13 13:28:50 +09:00
|
|
|
|
}
|
2026-01-14 14:35:27 +09:00
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
if (values.operations) {
|
|
|
|
|
|
comp.componentConfig.action.operations = values.operations;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// webTypeConfig 업데이트 (플로우 연동 - 다중 플로우 지원)
|
2026-01-13 13:28:50 +09:00
|
|
|
|
if (!comp.webTypeConfig) comp.webTypeConfig = {};
|
2026-01-14 14:35:27 +09:00
|
|
|
|
|
|
|
|
|
|
// 다중 플로우 처리 (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) {
|
2026-01-13 13:28:50 +09:00
|
|
|
|
comp.webTypeConfig.enableDataflowControl = true;
|
|
|
|
|
|
comp.webTypeConfig.dataflowConfig = {
|
|
|
|
|
|
controlMode: "flow",
|
|
|
|
|
|
flowConfig: {
|
|
|
|
|
|
flowId: values.linkedFlowId,
|
2026-01-14 14:35:27 +09:00
|
|
|
|
flowName: flows.find(f => f.flowId === values.linkedFlowId)?.flowName || "",
|
2026-01-13 13:28:50 +09:00
|
|
|
|
executionTiming: values.flowTiming || "after",
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
} else if (values.linkedFlowId === null) {
|
|
|
|
|
|
// 플로우 연동 해제
|
|
|
|
|
|
comp.webTypeConfig.enableDataflowControl = false;
|
|
|
|
|
|
delete comp.webTypeConfig.dataflowConfig;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// 자식 컴포넌트 처리
|
2026-01-13 13:28:50 +09:00
|
|
|
|
if (comp.children && Array.isArray(comp.children)) {
|
|
|
|
|
|
if (updateButton(comp.children)) return true;
|
|
|
|
|
|
}
|
2026-01-14 14:35:27 +09:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-01-13 13:28:50 +09:00
|
|
|
|
}
|
|
|
|
|
|
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("저장 실패");
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// 액션 타입 라벨 (화면 디자이너와 동일)
|
2026-01-13 13:28:50 +09:00
|
|
|
|
const getActionTypeLabel = (type: string) => {
|
|
|
|
|
|
const labels: Record<string, string> = {
|
|
|
|
|
|
save: "저장",
|
|
|
|
|
|
delete: "삭제",
|
2026-01-14 14:35:27 +09:00
|
|
|
|
edit: "편집",
|
|
|
|
|
|
copy: "복사",
|
|
|
|
|
|
navigate: "페이지 이동",
|
|
|
|
|
|
modal: "모달 열기",
|
|
|
|
|
|
openModalWithData: "데이터+모달",
|
|
|
|
|
|
openRelatedModal: "연관모달",
|
|
|
|
|
|
transferData: "데이터전달",
|
|
|
|
|
|
quickInsert: "즉시저장",
|
|
|
|
|
|
control: "제어흐름",
|
|
|
|
|
|
view_table_history: "이력보기",
|
|
|
|
|
|
excel_download: "엑셀다운",
|
|
|
|
|
|
excel_upload: "엑셀업로드",
|
|
|
|
|
|
barcode_scan: "바코드스캔",
|
|
|
|
|
|
code_merge: "코드병합",
|
|
|
|
|
|
operation_control: "운행제어",
|
2026-01-13 13:28:50 +09:00
|
|
|
|
};
|
|
|
|
|
|
return labels[type] || type;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// 액션 타입 색상 (화면 디자이너와 동일) - hover 상태 포함
|
2026-01-13 13:28:50 +09:00
|
|
|
|
const getActionTypeColor = (type: string) => {
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
|
case "save":
|
2026-01-14 14:35:27 +09:00
|
|
|
|
case "quickInsert":
|
|
|
|
|
|
return "bg-green-100 text-green-700 hover:bg-green-100 hover:text-green-700";
|
2026-01-13 13:28:50 +09:00
|
|
|
|
case "delete":
|
2026-01-14 14:35:27 +09:00
|
|
|
|
return "bg-red-100 text-red-700 hover:bg-red-100 hover:text-red-700";
|
|
|
|
|
|
case "edit":
|
|
|
|
|
|
case "copy":
|
|
|
|
|
|
return "bg-blue-100 text-blue-700 hover:bg-blue-100 hover:text-blue-700";
|
|
|
|
|
|
case "modal":
|
|
|
|
|
|
case "openModalWithData":
|
|
|
|
|
|
case "openRelatedModal":
|
|
|
|
|
|
return "bg-purple-100 text-purple-700 hover:bg-purple-100 hover:text-purple-700";
|
|
|
|
|
|
case "navigate":
|
|
|
|
|
|
return "bg-cyan-100 text-cyan-700 hover:bg-cyan-100 hover:text-cyan-700";
|
|
|
|
|
|
case "transferData":
|
|
|
|
|
|
case "control":
|
|
|
|
|
|
return "bg-amber-100 text-amber-700 hover:bg-amber-100 hover:text-amber-700";
|
|
|
|
|
|
case "excel_download":
|
|
|
|
|
|
case "excel_upload":
|
|
|
|
|
|
return "bg-emerald-100 text-emerald-700 hover:bg-emerald-100 hover:text-emerald-700";
|
|
|
|
|
|
case "view_table_history":
|
|
|
|
|
|
return "bg-slate-100 text-slate-700 hover:bg-slate-100 hover:text-slate-700";
|
2026-01-13 13:28:50 +09:00
|
|
|
|
default:
|
2026-01-14 14:35:27 +09:00
|
|
|
|
return "bg-gray-100 text-gray-700 hover:bg-gray-100 hover:text-gray-700";
|
2026-01-13 13:28:50 +09:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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 (
|
2026-01-14 14:35:27 +09:00
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
{/* 버튼 액션 설정 - 구분된 섹션 */}
|
|
|
|
|
|
<div className="rounded-lg border border-blue-200 bg-blue-50/30">
|
|
|
|
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-blue-200 bg-blue-100/50 rounded-t-lg">
|
|
|
|
|
|
<MousePointer className="h-4 w-4 text-blue-600" />
|
|
|
|
|
|
<span className="text-sm font-semibold text-blue-900">버튼 액션 설정</span>
|
|
|
|
|
|
<Badge className="h-5 text-[10px] bg-blue-600 text-white hover:bg-blue-600">
|
2026-01-13 13:28:50 +09:00
|
|
|
|
{buttonControls.length}개
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
<div className="max-h-[350px] overflow-y-auto p-2">
|
2026-01-13 13:28:50 +09:00
|
|
|
|
{buttonControls.length === 0 ? (
|
2026-01-14 14:35:27 +09:00
|
|
|
|
<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" />
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<p className="text-sm text-muted-foreground">버튼이 없습니다</p>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">화면 디자이너에서 버튼을 추가하세요</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-01-14 14:35:27 +09:00
|
|
|
|
<div className="divide-y">
|
|
|
|
|
|
{buttonControls.map((btn) => {
|
|
|
|
|
|
// 현재 편집 중인 값 또는 기본값
|
|
|
|
|
|
const currentLabel = editedValues[btn.id]?.label ?? btn.label;
|
|
|
|
|
|
const currentBgColor = editedValues[btn.id]?.backgroundColor ?? btn.backgroundColor ?? "#3b82f6";
|
|
|
|
|
|
const currentTextColor = editedValues[btn.id]?.textColor ?? btn.textColor ?? "#ffffff";
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={btn.id} className="py-3 px-1">
|
|
|
|
|
|
{/* 버튼 헤더: 프리뷰 + 이름 입력 + 저장 버튼 */}
|
|
|
|
|
|
<div className="flex items-center gap-3 mb-3">
|
|
|
|
|
|
{/* 버튼 프리뷰 */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium min-w-[60px] shrink-0"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
backgroundColor: currentBgColor,
|
|
|
|
|
|
color: currentTextColor,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{currentLabel || "버튼"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 버튼 이름 입력 */}
|
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={currentLabel}
|
|
|
|
|
|
onChange={(e) => setEditedValues(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[btn.id]: { ...prev[btn.id], label: e.target.value }
|
|
|
|
|
|
}))}
|
|
|
|
|
|
className="h-7 text-sm"
|
|
|
|
|
|
placeholder="버튼 이름"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
{btn.hasDataflowControl && (
|
2026-01-14 14:35:27 +09:00
|
|
|
|
<Badge variant="outline" className="h-5 text-[10px] border-purple-300 text-purple-600 shrink-0">
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<Zap className="mr-1 h-2.5 w-2.5" />
|
2026-01-14 14:35:27 +09:00
|
|
|
|
제어
|
2026-01-13 13:28:50 +09:00
|
|
|
|
</Badge>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
2026-01-14 14:35:27 +09:00
|
|
|
|
className="h-7 text-xs px-3 shrink-0"
|
|
|
|
|
|
onClick={() => handleSaveButton(btn.id)}
|
|
|
|
|
|
disabled={!editedValues[btn.id]}
|
2026-01-13 13:28:50 +09:00
|
|
|
|
>
|
2026-01-14 14:35:27 +09:00
|
|
|
|
<Save className="mr-1 h-3 w-3" />
|
|
|
|
|
|
저장
|
2026-01-13 13:28:50 +09:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
{/* 버튼 설정 (상시 편집) */}
|
|
|
|
|
|
<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"
|
|
|
|
|
|
/>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
</div>
|
2026-01-14 14:35:27 +09:00
|
|
|
|
{/* 글자색 */}
|
|
|
|
|
|
<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 => ({
|
2026-01-13 13:28:50 +09:00
|
|
|
|
...prev,
|
2026-01-14 14:35:27 +09:00
|
|
|
|
[btn.id]: { ...prev[btn.id], backgroundColor: preset.bg, textColor: preset.text }
|
2026-01-13 13:28:50 +09:00
|
|
|
|
}))}
|
2026-01-14 14:35:27 +09:00
|
|
|
|
className="h-5 w-5 rounded border border-gray-300 hover:ring-2 hover:ring-blue-300 transition-all"
|
|
|
|
|
|
style={{ backgroundColor: preset.bg }}
|
|
|
|
|
|
title={preset.name}
|
2026-01-13 13:28:50 +09:00
|
|
|
|
/>
|
2026-01-14 14:35:27 +09:00
|
|
|
|
))}
|
2026-01-13 13:28:50 +09:00
|
|
|
|
</div>
|
2026-01-14 14:35:27 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
{/* 확인 메시지 설정 (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>
|
|
|
|
|
|
)}
|
2026-01-13 13:28:50 +09:00
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
{/* 모달 화면 선택 (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" />
|
2026-01-13 13:28:50 +09:00
|
|
|
|
</Button>
|
2026-01-14 14:35:27 +09:00
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
|
|
|
|
|
<Command>
|
|
|
|
|
|
<CommandInput placeholder="화면 검색..." className="text-xs" />
|
|
|
|
|
|
<CommandList>
|
|
|
|
|
|
<CommandEmpty className="text-xs py-2 text-center">화면을 찾을 수 없습니다</CommandEmpty>
|
|
|
|
|
|
<CommandItem
|
|
|
|
|
|
value="__none__"
|
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
|
setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], modalScreenId: null } }));
|
|
|
|
|
|
setOpenModalScreenSearch(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-xs text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check className={cn("mr-2 h-3 w-3", !(editedValues[btn.id]?.modalScreenId || btn.modalScreenId) ? "opacity-100" : "opacity-0")} />
|
|
|
|
|
|
미설정
|
|
|
|
|
|
</CommandItem>
|
|
|
|
|
|
{screenList.filter(s => s && s.id != null && s.inGroup).length > 0 && (
|
|
|
|
|
|
<CommandGroup heading="현재 그룹">
|
|
|
|
|
|
{screenList.filter(s => s && s.id != null && s.inGroup).map((s) => (
|
|
|
|
|
|
<CommandItem
|
|
|
|
|
|
key={s.id}
|
|
|
|
|
|
value={s.name}
|
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
|
setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], modalScreenId: s.id } }));
|
|
|
|
|
|
setOpenModalScreenSearch(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check className={cn("mr-2 h-3 w-3", Number(editedValues[btn.id]?.modalScreenId || btn.modalScreenId) === s.id ? "opacity-100" : "opacity-0")} />
|
|
|
|
|
|
{s.name}
|
|
|
|
|
|
</CommandItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{screenList.filter(s => s && s.id != null && !s.inGroup).length > 0 && (
|
|
|
|
|
|
<CommandGroup heading="다른 그룹">
|
|
|
|
|
|
{screenList.filter(s => s && s.id != null && !s.inGroup).map((s) => (
|
|
|
|
|
|
<CommandItem
|
|
|
|
|
|
key={s.id}
|
|
|
|
|
|
value={s.name}
|
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
|
setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], modalScreenId: s.id } }));
|
|
|
|
|
|
setOpenModalScreenSearch(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-xs text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check className={cn("mr-2 h-3 w-3", Number(editedValues[btn.id]?.modalScreenId || btn.modalScreenId) === s.id ? "opacity-100" : "opacity-0")} />
|
|
|
|
|
|
{s.name}
|
|
|
|
|
|
</CommandItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CommandList>
|
|
|
|
|
|
</Command>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 네비게이션 화면 선택 (navigate 액션) */}
|
|
|
|
|
|
{(editedValues[btn.id]?.actionType || btn.actionType) === "navigate" && (
|
|
|
|
|
|
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
|
|
|
|
|
|
<Label className="text-xs text-muted-foreground">이동 화면</Label>
|
|
|
|
|
|
<Popover open={openNavigateScreenSearch === btn.id} onOpenChange={(open) => setOpenNavigateScreenSearch(open ? btn.id : null)}>
|
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
|
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
|
|
|
|
|
{editedValues[btn.id]?.navigateScreenId || btn.navigateScreenId
|
|
|
|
|
|
? screenList.find(s => s.id === Number(editedValues[btn.id]?.navigateScreenId || btn.navigateScreenId))?.name || "화면 선택"
|
|
|
|
|
|
: "화면 선택"
|
|
|
|
|
|
}
|
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
|
|
|
|
|
<Command>
|
|
|
|
|
|
<CommandInput placeholder="화면 검색..." className="text-xs" />
|
|
|
|
|
|
<CommandList>
|
|
|
|
|
|
<CommandEmpty className="text-xs py-2 text-center">화면을 찾을 수 없습니다</CommandEmpty>
|
|
|
|
|
|
<CommandItem
|
|
|
|
|
|
value="__none__"
|
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
|
setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], navigateScreenId: null } }));
|
|
|
|
|
|
setOpenNavigateScreenSearch(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-xs text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check className={cn("mr-2 h-3 w-3", !(editedValues[btn.id]?.navigateScreenId || btn.navigateScreenId) ? "opacity-100" : "opacity-0")} />
|
|
|
|
|
|
미설정
|
|
|
|
|
|
</CommandItem>
|
|
|
|
|
|
{screenList.filter(s => s && s.id != null && s.inGroup).length > 0 && (
|
|
|
|
|
|
<CommandGroup heading="현재 그룹">
|
|
|
|
|
|
{screenList.filter(s => s && s.id != null && s.inGroup).map((s) => (
|
|
|
|
|
|
<CommandItem
|
|
|
|
|
|
key={s.id}
|
|
|
|
|
|
value={s.name}
|
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
|
setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], navigateScreenId: s.id } }));
|
|
|
|
|
|
setOpenNavigateScreenSearch(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check className={cn("mr-2 h-3 w-3", Number(editedValues[btn.id]?.navigateScreenId || btn.navigateScreenId) === s.id ? "opacity-100" : "opacity-0")} />
|
|
|
|
|
|
{s.name}
|
|
|
|
|
|
</CommandItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{screenList.filter(s => s && s.id != null && !s.inGroup).length > 0 && (
|
|
|
|
|
|
<CommandGroup heading="다른 그룹">
|
|
|
|
|
|
{screenList.filter(s => s && s.id != null && !s.inGroup).map((s) => (
|
|
|
|
|
|
<CommandItem
|
|
|
|
|
|
key={s.id}
|
|
|
|
|
|
value={s.name}
|
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
|
setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], navigateScreenId: s.id } }));
|
|
|
|
|
|
setOpenNavigateScreenSearch(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-xs text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check className={cn("mr-2 h-3 w-3", Number(editedValues[btn.id]?.navigateScreenId || btn.navigateScreenId) === s.id ? "opacity-100" : "opacity-0")} />
|
|
|
|
|
|
{s.name}
|
|
|
|
|
|
</CommandItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CommandList>
|
|
|
|
|
|
</Command>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 플로우 연동 - 세로 목록 형식 */}
|
|
|
|
|
|
<div className="grid grid-cols-[80px_1fr] items-start gap-2">
|
|
|
|
|
|
<Label className="text-xs text-muted-foreground pt-1">플로우</Label>
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
{/* 연동된 플로우 목록 (세로 형식, 각각 타이밍 선택) */}
|
|
|
|
|
|
{(() => {
|
|
|
|
|
|
const currentFlows = editedValues[btn.id]?.linkedFlows || btn.linkedFlows || [];
|
|
|
|
|
|
return currentFlows.length > 0 ? (
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
{currentFlows.map((lf: { id: number; name: string; timing?: string }, idx: number) => (
|
|
|
|
|
|
<div key={lf.id} className="flex items-center gap-2 p-1.5 border rounded-md bg-muted/30">
|
|
|
|
|
|
<Workflow className="h-3 w-3 text-purple-500 shrink-0" />
|
|
|
|
|
|
<span className="text-xs font-medium text-gray-700 flex-1 truncate">
|
|
|
|
|
|
{lf.name}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{/* 타이밍 선택 */}
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={lf.timing || "after"}
|
|
|
|
|
|
onValueChange={(val) => {
|
|
|
|
|
|
const newFlows = [...currentFlows];
|
|
|
|
|
|
newFlows[idx] = { ...lf, timing: val };
|
|
|
|
|
|
setEditedValues(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[btn.id]: { ...prev[btn.id], linkedFlows: newFlows }
|
|
|
|
|
|
}));
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger className="h-6 w-[70px] text-[10px]">
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="before" className="text-[10px]">실행 전</SelectItem>
|
|
|
|
|
|
<SelectItem value="after" className="text-[10px]">실행 후</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
{/* 삭제 버튼 */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="h-5 w-5 rounded hover:bg-red-100 flex items-center justify-center text-gray-400 hover:text-red-500"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
const newFlows = currentFlows.filter((f: any) => f.id !== lf.id);
|
|
|
|
|
|
setEditedValues(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[btn.id]: { ...prev[btn.id], linkedFlows: newFlows }
|
|
|
|
|
|
}));
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="h-3 w-3" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null;
|
|
|
|
|
|
})()}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 플로우 추가 버튼 */}
|
|
|
|
|
|
<Popover open={openFlowSearch === btn.id} onOpenChange={(open) => setOpenFlowSearch(open ? btn.id : null)}>
|
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
|
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
|
|
|
|
|
<span className="flex items-center gap-1">
|
|
|
|
|
|
<Plus className="h-3 w-3" />
|
|
|
|
|
|
플로우 추가
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
|
|
|
|
|
<Command>
|
|
|
|
|
|
<CommandInput placeholder="플로우 검색..." className="text-xs" />
|
|
|
|
|
|
<CommandList>
|
|
|
|
|
|
<CommandEmpty className="text-xs py-2 text-center">플로우를 찾을 수 없습니다</CommandEmpty>
|
|
|
|
|
|
{/* 빠른 생성 옵션 */}
|
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
|
<CommandItem
|
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
|
setOpenFlowSearch(null);
|
|
|
|
|
|
// FlowEditor 모달 열기 (버튼 연동)
|
|
|
|
|
|
setFlowEditorTargetButtonId(btn.id);
|
|
|
|
|
|
setShowFlowEditorModal(true);
|
|
|
|
|
|
setOpenFlowSearch(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-xs text-purple-700 bg-purple-50 hover:bg-purple-100"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plus className="mr-2 h-3 w-3 text-purple-500" />
|
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
|
<span className="font-medium">새 플로우 생성</span>
|
|
|
|
|
|
<span className="text-[10px] text-purple-500">여기서 직접 만들고 자동 연동</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CommandItem>
|
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
|
{flows.length > 0 ? (
|
|
|
|
|
|
<CommandGroup heading="플로우 목록">
|
|
|
|
|
|
{flows.map((f) => {
|
|
|
|
|
|
const currentFlows = editedValues[btn.id]?.linkedFlows || btn.linkedFlows || [];
|
|
|
|
|
|
const isLinked = currentFlows.some((lf: any) => lf.id === f.flowId);
|
|
|
|
|
|
const tableName = typeof f.flowData === 'object'
|
|
|
|
|
|
? f.flowData?.nodes?.find((n: any) => n.type === 'tableSource')?.data?.tableName
|
|
|
|
|
|
: null;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<CommandItem
|
|
|
|
|
|
key={f.flowId}
|
|
|
|
|
|
value={f.flowName}
|
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
|
if (!isLinked) {
|
|
|
|
|
|
const newFlows = [...currentFlows, { id: f.flowId, name: f.flowName, timing: "after" }];
|
|
|
|
|
|
setEditedValues(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[btn.id]: { ...prev[btn.id], linkedFlows: newFlows }
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
setOpenFlowSearch(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className={cn("text-xs", isLinked && "opacity-50")}
|
|
|
|
|
|
disabled={isLinked}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check className={cn("mr-2 h-3 w-3", isLinked ? "opacity-100" : "opacity-0")} />
|
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
|
<span>{f.flowName}</span>
|
|
|
|
|
|
{tableName && <span className="text-[10px] text-muted-foreground">{tableName}</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{isLinked && <span className="ml-auto text-[10px] text-muted-foreground">연동됨</span>}
|
|
|
|
|
|
</CommandItem>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="py-2 px-2 text-xs text-muted-foreground text-center">
|
|
|
|
|
|
등록된 플로우가 없습니다
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CommandList>
|
|
|
|
|
|
</Command>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-14 14:35:27 +09:00
|
|
|
|
</div>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
</div>
|
2026-01-14 14:35:27 +09:00
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-01-13 13:28:50 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
{/* 플로우 연동 - 구분된 섹션 */}
|
|
|
|
|
|
<div className="rounded-lg border border-purple-200 bg-purple-50/30">
|
|
|
|
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-purple-200 bg-purple-100/50 rounded-t-lg">
|
|
|
|
|
|
<Workflow className="h-4 w-4 text-purple-600" />
|
|
|
|
|
|
<span className="text-sm font-semibold text-purple-900">플로우 연동 현황</span>
|
|
|
|
|
|
<Badge className="h-5 text-[10px] bg-purple-600 text-white hover:bg-purple-600">
|
|
|
|
|
|
{flows.length}개 플로우
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
<Badge className="h-5 text-[10px] bg-green-600 text-white hover:bg-green-600">
|
|
|
|
|
|
{buttonControls.filter(b => (b.linkedFlows && b.linkedFlows.length > 0) || b.linkedFlow).length}개 연동
|
2026-01-13 13:28:50 +09:00
|
|
|
|
</Badge>
|
2026-01-14 14:35:27 +09:00
|
|
|
|
<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>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
</div>
|
2026-01-14 14:35:27 +09:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-01-14 14:35:27 +09:00
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
{flows.map((flow) => {
|
|
|
|
|
|
// 이 플로우가 연동된 버튼들 찾기 (다중 플로우 지원)
|
|
|
|
|
|
const linkedButtons = buttonControls.filter(b =>
|
|
|
|
|
|
(b.linkedFlows && b.linkedFlows.some(lf => lf.id === flow.flowId)) ||
|
|
|
|
|
|
b.linkedFlow?.id === flow.flowId
|
|
|
|
|
|
);
|
|
|
|
|
|
const tableName = (flow as any).tableType || (flow as any).tableName;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={`flow-list-${flow.flowId}`} className="flex items-center gap-2 px-2 py-1.5 rounded bg-white/50 hover:bg-white">
|
|
|
|
|
|
{/* 플로우 이름 - 일반 텍스트 */}
|
|
|
|
|
|
<Workflow className="h-3.5 w-3.5 text-purple-500 shrink-0" />
|
|
|
|
|
|
<span className="text-xs font-medium text-gray-700 shrink-0">
|
|
|
|
|
|
{flow.flowName}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{tableName && (
|
|
|
|
|
|
<span className="text-[10px] text-muted-foreground shrink-0">
|
|
|
|
|
|
({tableName})
|
|
|
|
|
|
</span>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
)}
|
2026-01-14 14:35:27 +09:00
|
|
|
|
<span className="flex-1" />
|
|
|
|
|
|
{linkedButtons.length > 0 ? (
|
|
|
|
|
|
<div className="flex items-center gap-1 shrink-0 flex-wrap justify-end">
|
|
|
|
|
|
{linkedButtons.map((btn, idx) => {
|
|
|
|
|
|
// 해당 버튼에서 이 플로우의 타이밍 정보 추출
|
|
|
|
|
|
const flowInfo = btn.linkedFlows?.find(lf => lf.id === flow.flowId);
|
|
|
|
|
|
const timing = flowInfo?.timing || btn.flowTiming || "after";
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={`${flow.flowId}-${btn.id}`} className="flex items-center gap-1">
|
|
|
|
|
|
{idx === 0 && <ArrowRight className="h-3 w-3 text-green-500" />}
|
|
|
|
|
|
<span className="text-xs text-green-600 font-medium">
|
|
|
|
|
|
[{btn.label}]
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Badge className={cn("h-4 text-[9px]", getActionTypeColor(btn.actionType))}>
|
|
|
|
|
|
{getActionTypeLabel(btn.actionType)}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">
|
|
|
|
|
|
({timing === "before" ? "전" : "후"})
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
/* 미연동 - 보라색 뱃지 */
|
|
|
|
|
|
<Badge variant="outline" className="h-5 text-[10px] border-purple-300 bg-purple-100 text-purple-600 shrink-0">
|
|
|
|
|
|
미연동
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-01-13 13:28:50 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-01-14 14:35:27 +09:00
|
|
|
|
</div>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
{/* 플로우 빠른 생성 다이얼로그 */}
|
|
|
|
|
|
<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>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
<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>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
{/* 테이블 선택/입력 */}
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs sm:text-sm">대상 테이블 *</Label>
|
|
|
|
|
|
{(() => {
|
|
|
|
|
|
const availableTables = Array.from(new Set(buttonControls.filter(b => b.targetTable).map(b => b.targetTable)));
|
|
|
|
|
|
|
|
|
|
|
|
return availableTables.length > 0 ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Popover>
|
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
role="combobox"
|
|
|
|
|
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm mt-1"
|
|
|
|
|
|
>
|
|
|
|
|
|
{quickFlowData.tableName
|
|
|
|
|
|
? (quickFlowData.tableLabel || quickFlowData.tableName)
|
|
|
|
|
|
: "테이블 선택..."}
|
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
|
|
|
|
|
<Command>
|
|
|
|
|
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
|
|
|
|
|
<CommandList>
|
|
|
|
|
|
<CommandEmpty className="text-xs py-2 text-center">테이블을 찾을 수 없습니다</CommandEmpty>
|
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
|
{availableTables.map((table) => (
|
|
|
|
|
|
<CommandItem
|
|
|
|
|
|
key={table}
|
|
|
|
|
|
value={table}
|
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
|
setQuickFlowData(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
tableName: table || "",
|
|
|
|
|
|
tableLabel: table || "",
|
|
|
|
|
|
}));
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check className={cn("mr-2 h-3 w-3", quickFlowData.tableName === table ? "opacity-100" : "opacity-0")} />
|
|
|
|
|
|
<Database className="mr-2 h-3 w-3 text-blue-500" />
|
|
|
|
|
|
{table}
|
|
|
|
|
|
</CommandItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
|
</CommandList>
|
|
|
|
|
|
</Command>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
<p className="text-[10px] text-muted-foreground mt-1">화면에서 사용 중인 테이블 목록입니다</p>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={quickFlowData.tableName}
|
|
|
|
|
|
onChange={(e) => setQuickFlowData(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
tableName: e.target.value,
|
|
|
|
|
|
tableLabel: e.target.value,
|
|
|
|
|
|
}))}
|
|
|
|
|
|
placeholder="테이블명 입력 (예: customer_mng)"
|
|
|
|
|
|
className="h-8 text-xs sm:h-10 sm:text-sm mt-1"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p className="text-[10px] text-muted-foreground mt-1">테이블명을 직접 입력하세요</p>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
})()}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 액션 타입 */}
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs sm:text-sm">액션 타입</Label>
|
|
|
|
|
|
<div className="flex gap-2 mt-2">
|
|
|
|
|
|
{[
|
|
|
|
|
|
{ value: "insert", label: "INSERT", color: "bg-green-100 text-green-700 border-green-300" },
|
|
|
|
|
|
{ value: "update", label: "UPDATE", color: "bg-blue-100 text-blue-700 border-blue-300" },
|
|
|
|
|
|
{ value: "delete", label: "DELETE", color: "bg-red-100 text-red-700 border-red-300" },
|
|
|
|
|
|
].map((action) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={action.value}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setQuickFlowData(prev => ({ ...prev, actionType: action.value as any }))}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex-1 py-2 px-3 text-xs font-medium rounded-md border transition-all",
|
|
|
|
|
|
quickFlowData.actionType === action.value
|
|
|
|
|
|
? action.color + " ring-2 ring-offset-1"
|
|
|
|
|
|
: "bg-gray-50 text-gray-600 border-gray-200 hover:bg-gray-100"
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{action.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 설명 (선택) */}
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label className="text-xs sm:text-sm">설명 (선택)</Label>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
value={quickFlowData.description}
|
|
|
|
|
|
onChange={(e) => setQuickFlowData(prev => ({ ...prev, description: e.target.value }))}
|
|
|
|
|
|
placeholder="플로우에 대한 설명을 입력하세요"
|
|
|
|
|
|
className="h-16 text-xs sm:text-sm mt-1 resize-none"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 자동 연동 옵션 */}
|
|
|
|
|
|
{quickFlowData.targetButtonId && (
|
|
|
|
|
|
<div className="flex items-center gap-2 p-2 rounded-md bg-purple-50 border border-purple-200">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
id="autoLink"
|
|
|
|
|
|
checked={quickFlowData.autoLink}
|
|
|
|
|
|
onChange={(e) => setQuickFlowData(prev => ({ ...prev, autoLink: e.target.checked }))}
|
|
|
|
|
|
className="h-4 w-4 rounded border-gray-300"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<label htmlFor="autoLink" className="text-xs text-purple-700">
|
|
|
|
|
|
생성 후 [{buttonControls.find(b => b.id === quickFlowData.targetButtonId)?.label}] 버튼에 자동 연동
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 버튼 영역 */}
|
|
|
|
|
|
<div className="flex flex-col gap-2 pt-2 border-t">
|
|
|
|
|
|
{/* 제어 관리에서 직접 생성 (권장) */}
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="default"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
if (quickFlowData.targetButtonId) {
|
|
|
|
|
|
setPendingLinkButtonId(quickFlowData.targetButtonId);
|
|
|
|
|
|
}
|
|
|
|
|
|
setShowQuickFlowDialog(false);
|
|
|
|
|
|
openFlowEditorInNewWindow(quickFlowData.targetButtonId || undefined);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="h-10 w-full text-sm font-medium"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ExternalLink className="mr-2 h-4 w-4" />
|
|
|
|
|
|
제어 관리에서 직접 생성 (권장)
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<p className="text-[10px] text-center text-muted-foreground -mt-1">
|
|
|
|
|
|
필드 매핑, WHERE 조건 등을 직접 설정할 수 있습니다. 저장 시 자동 연동됩니다.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 구분선 */}
|
|
|
|
|
|
<div className="flex items-center gap-2 my-1">
|
|
|
|
|
|
<div className="flex-1 h-px bg-border" />
|
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">또는</span>
|
|
|
|
|
|
<div className="flex-1 h-px bg-border" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 하단 버튼들 */}
|
|
|
|
|
|
<div className="flex justify-end gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
onClick={() => setShowQuickFlowDialog(false)}
|
|
|
|
|
|
className="h-8 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
취소
|
|
|
|
|
|
</Button>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<Button
|
2026-01-14 14:35:27 +09:00
|
|
|
|
variant="outline"
|
|
|
|
|
|
onClick={handleQuickCreateFlow}
|
|
|
|
|
|
disabled={isCreatingFlow || !quickFlowData.name.trim() || !quickFlowData.tableName}
|
|
|
|
|
|
className="h-8 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
{isCreatingFlow ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
|
|
|
|
|
생성 중...
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
"골격만 생성"
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
|
|
{/* FlowEditor 전체 화면 모달 */}
|
|
|
|
|
|
<Dialog open={showFlowEditorModal} onOpenChange={setShowFlowEditorModal}>
|
|
|
|
|
|
<DialogContent className="max-w-[98vw] h-[95vh] p-0 overflow-hidden">
|
|
|
|
|
|
<div className="flex flex-col h-full">
|
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
|
<div className="flex items-center justify-between px-4 py-3 border-b bg-background">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 className="text-lg font-semibold">플로우 생성</h2>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
|
저장하면 자동으로 버튼에 연동됩니다
|
|
|
|
|
|
{flowEditorTargetButtonId && (
|
|
|
|
|
|
<span className="ml-1 text-purple-600">
|
|
|
|
|
|
(대상: {buttonControls.find(b => b.id === flowEditorTargetButtonId)?.label})
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
2026-01-13 13:28:50 +09:00
|
|
|
|
size="sm"
|
2026-01-14 14:35:27 +09:00
|
|
|
|
onClick={() => setShowFlowEditorModal(false)}
|
2026-01-13 13:28:50 +09:00
|
|
|
|
>
|
2026-01-14 14:35:27 +09:00
|
|
|
|
<X className="h-4 w-4" />
|
2026-01-13 13:28:50 +09:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-01-14 14:35:27 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 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);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-14 14:35:27 +09:00
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 17:03:00 +09:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 탭 4: 화면 프리뷰 (iframe)
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
interface PreviewTabProps {
|
|
|
|
|
|
screenId: number;
|
|
|
|
|
|
screenName: string;
|
2026-01-09 18:26:37 +09:00
|
|
|
|
companyCode?: string;
|
2026-01-13 13:28:50 +09:00
|
|
|
|
iframeKey?: number; // iframe 새로고침용 키
|
2026-01-14 14:35:27 +09:00
|
|
|
|
canvasWidth?: number; // 화면 캔버스 너비
|
|
|
|
|
|
canvasHeight?: number; // 화면 캔버스 높이
|
2026-01-09 17:03:00 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight }: PreviewTabProps) {
|
2026-01-09 17:03:00 +09:00
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
2026-01-13 13:28:50 +09:00
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
|
// 화면 디자인 크기 (실제 캔버스 크기 사용, 없으면 기본값)
|
|
|
|
|
|
// 좌우/상하 여유 마진 추가: 모달 패딩, 헤더, 하단 체크박스 등
|
|
|
|
|
|
// 좌우: +120px (양쪽 패딩 + 여유), 상하: +250px (헤더 + 버튼 + 체크박스 + 패딩)
|
|
|
|
|
|
const designWidth = Math.max((canvasWidth || 400) + 120, 500);
|
|
|
|
|
|
const designHeight = Math.max((canvasHeight || 400) + 250, 650);
|
2026-01-13 13:28:50 +09:00
|
|
|
|
|
|
|
|
|
|
// 컨테이너에 맞는 초기 스케일 계산
|
|
|
|
|
|
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();
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-01-09 17:03:00 +09:00
|
|
|
|
|
2026-01-09 18:26:37 +09:00
|
|
|
|
// 화면 URL 생성 (preview=true로 사이드바 없이 화면만 표시, company_code 전달)
|
2026-01-09 17:03:00 +09:00
|
|
|
|
const previewUrl = useMemo(() => {
|
|
|
|
|
|
// 현재 호스트 기반으로 URL 생성
|
2026-01-09 18:26:37 +09:00
|
|
|
|
const params = new URLSearchParams({ preview: "true" });
|
|
|
|
|
|
// 프리뷰용 회사 코드 추가 (데이터 조회에 필요)
|
|
|
|
|
|
if (companyCode) {
|
|
|
|
|
|
params.set("company_code", companyCode);
|
|
|
|
|
|
}
|
2026-01-09 17:03:00 +09:00
|
|
|
|
if (typeof window !== "undefined") {
|
|
|
|
|
|
const baseUrl = window.location.origin;
|
2026-01-09 18:26:37 +09:00
|
|
|
|
return `${baseUrl}/screens/${screenId}?${params.toString()}`;
|
2026-01-09 17:03:00 +09:00
|
|
|
|
}
|
2026-01-09 18:26:37 +09:00
|
|
|
|
return `/screens/${screenId}?${params.toString()}`;
|
|
|
|
|
|
}, [screenId, companyCode]);
|
2026-01-09 17:03:00 +09:00
|
|
|
|
|
|
|
|
|
|
const handleIframeLoad = () => {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleIframeError = () => {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
setError("화면을 불러오는데 실패했습니다.");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const openInNewTab = () => {
|
|
|
|
|
|
window.open(previewUrl, "_blank");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
|
|
|
|
{/* 상단 툴바 (최소화) */}
|
|
|
|
|
|
<div className="flex h-7 shrink-0 items-center justify-between border-b px-2">
|
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
|
<Eye className="h-3 w-3 text-blue-500" />
|
|
|
|
|
|
<span className="truncate text-xs font-medium">{screenName}</span>
|
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">(휠: 확대/축소, 드래그: 이동)</span>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
</div>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<div className="flex items-center gap-1">
|
2026-01-09 17:03:00 +09:00
|
|
|
|
<Button
|
2026-01-13 13:28:50 +09:00
|
|
|
|
variant="ghost"
|
2026-01-09 17:03:00 +09:00
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
const iframe = document.getElementById("screen-preview-iframe") as HTMLIFrameElement;
|
|
|
|
|
|
if (iframe) {
|
|
|
|
|
|
iframe.src = iframe.src;
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
2026-01-13 13:28:50 +09:00
|
|
|
|
className="h-5 w-5 p-0"
|
|
|
|
|
|
title="새로고침"
|
2026-01-09 17:03:00 +09:00
|
|
|
|
>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<RefreshCw className={cn("h-3 w-3", loading && "animate-spin")} />
|
2026-01-09 17:03:00 +09:00
|
|
|
|
</Button>
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<Button variant="ghost" size="sm" onClick={openInNewTab} className="h-5 w-5 p-0" title="새 탭에서 열기">
|
|
|
|
|
|
<ExternalLink className="h-3 w-3" />
|
2026-01-09 17:03:00 +09:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
{/* iframe 영역 - Ctrl+휠로 확대/축소, 내부 버튼/목록 클릭 가능 */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={containerRef}
|
|
|
|
|
|
className="relative min-h-0 flex-1 overflow-hidden flex items-center justify-center bg-gray-100"
|
|
|
|
|
|
>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
{loading && (
|
|
|
|
|
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/80">
|
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<Loader2 className="mx-auto h-8 w-8 animate-spin text-primary" />
|
|
|
|
|
|
<p className="mt-2 text-sm text-muted-foreground">화면 로딩 중...</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{error ? (
|
|
|
|
|
|
<div className="flex h-full items-center justify-center">
|
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
|
|
|
|
|
|
<span className="text-2xl">⚠️</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-sm text-destructive">{error}</p>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="mt-4"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
다시 시도
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-01-13 13:28:50 +09:00
|
|
|
|
<TransformWrapper
|
|
|
|
|
|
initialScale={initialScale}
|
|
|
|
|
|
minScale={0.2}
|
|
|
|
|
|
maxScale={3}
|
|
|
|
|
|
centerOnInit={true}
|
|
|
|
|
|
wheel={{ step: 0.05 }}
|
|
|
|
|
|
panning={{ velocityDisabled: true }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{({ state }) => (
|
|
|
|
|
|
<TransformComponent
|
|
|
|
|
|
wrapperStyle={{ width: "100%", height: "100%" }}
|
|
|
|
|
|
contentStyle={{ display: "flex", alignItems: "center", justifyContent: "center" }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="relative"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
width: `${designWidth}px`,
|
|
|
|
|
|
height: `${designHeight}px`,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<iframe
|
|
|
|
|
|
key={iframeKey}
|
|
|
|
|
|
id="screen-preview-iframe"
|
|
|
|
|
|
src={previewUrl}
|
|
|
|
|
|
className="border-0 shadow-lg rounded bg-white pointer-events-none"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
width: "100%",
|
|
|
|
|
|
height: "100%",
|
|
|
|
|
|
}}
|
|
|
|
|
|
onLoad={handleIframeLoad}
|
|
|
|
|
|
onError={handleIframeError}
|
|
|
|
|
|
title={`화면 프리뷰: ${screenName}`}
|
|
|
|
|
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{/* 클릭/드래그 분리 오버레이 */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="absolute inset-0"
|
|
|
|
|
|
style={{ cursor: "grab" }}
|
|
|
|
|
|
onMouseDown={(e) => {
|
|
|
|
|
|
const overlay = e.currentTarget;
|
|
|
|
|
|
const iframe = overlay.previousElementSibling as HTMLIFrameElement;
|
|
|
|
|
|
const startX = e.clientX;
|
|
|
|
|
|
const startY = e.clientY;
|
|
|
|
|
|
let moved = false;
|
|
|
|
|
|
|
|
|
|
|
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
|
|
|
|
|
const dx = Math.abs(moveEvent.clientX - startX);
|
|
|
|
|
|
const dy = Math.abs(moveEvent.clientY - startY);
|
|
|
|
|
|
if (dx > 5 || dy > 5) {
|
|
|
|
|
|
moved = true;
|
|
|
|
|
|
overlay.style.cursor = "grabbing";
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleMouseUp = (upEvent: MouseEvent) => {
|
|
|
|
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
|
|
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
|
|
|
|
overlay.style.cursor = "grab";
|
|
|
|
|
|
|
|
|
|
|
|
// 이동 없이 클릭만 했으면 iframe에 클릭 전달
|
|
|
|
|
|
if (!moved) {
|
|
|
|
|
|
const rect = iframe.getBoundingClientRect();
|
|
|
|
|
|
// 실제 표시된 크기와 원본 크기의 비율로 좌표 변환
|
|
|
|
|
|
const scaleX = designWidth / rect.width;
|
|
|
|
|
|
const scaleY = designHeight / rect.height;
|
|
|
|
|
|
const x = (upEvent.clientX - rect.left) * scaleX;
|
|
|
|
|
|
const y = (upEvent.clientY - rect.top) * scaleY;
|
|
|
|
|
|
|
|
|
|
|
|
// iframe 내부로 클릭 이벤트 전달
|
|
|
|
|
|
try {
|
|
|
|
|
|
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
|
|
|
|
|
|
if (iframeDoc) {
|
|
|
|
|
|
let elem = iframeDoc.elementFromPoint(x, y) as HTMLElement | null;
|
|
|
|
|
|
if (elem) {
|
|
|
|
|
|
// SVG 내부 요소(path, line 등)면 가장 가까운 버튼/앵커 찾기
|
|
|
|
|
|
const clickable = elem.closest("button, a, [role='button'], [onclick]") as HTMLElement | null;
|
|
|
|
|
|
const target = clickable || elem;
|
|
|
|
|
|
|
|
|
|
|
|
// 인풋 요소면 포커스 먼저
|
|
|
|
|
|
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") {
|
|
|
|
|
|
target.focus();
|
|
|
|
|
|
}
|
|
|
|
|
|
// 전체 마우스 이벤트 시퀀스 발생
|
|
|
|
|
|
target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: iframe.contentWindow }));
|
|
|
|
|
|
target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: iframe.contentWindow }));
|
|
|
|
|
|
target.click();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// cross-origin 제한시 무시
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
|
|
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TransformComponent>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</TransformWrapper>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default ScreenSettingModal;
|
|
|
|
|
|
|