1343 lines
45 KiB
TypeScript
1343 lines
45 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from "@/components/ui/popover";
|
||
import {
|
||
Command,
|
||
CommandEmpty,
|
||
CommandGroup,
|
||
CommandInput,
|
||
CommandItem,
|
||
CommandList,
|
||
} from "@/components/ui/command";
|
||
import { toast } from "sonner";
|
||
import { cn } from "@/lib/utils";
|
||
import {
|
||
Database,
|
||
Link2,
|
||
GitBranch,
|
||
Columns3,
|
||
Eye,
|
||
Save,
|
||
Plus,
|
||
Pencil,
|
||
Trash2,
|
||
RefreshCw,
|
||
Loader2,
|
||
Check,
|
||
ChevronsUpDown,
|
||
ExternalLink,
|
||
Table2,
|
||
ArrowRight,
|
||
Settings2,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
Filter,
|
||
} from "lucide-react";
|
||
import {
|
||
getDataFlows,
|
||
createDataFlow,
|
||
updateDataFlow,
|
||
deleteDataFlow,
|
||
DataFlow,
|
||
getMultipleScreenLayoutSummary,
|
||
LayoutItem,
|
||
} from "@/lib/api/screenGroup";
|
||
import { tableManagementApi, ColumnTypeInfo, TableInfo } from "@/lib/api/tableManagement";
|
||
|
||
// ============================================================
|
||
// 타입 정의
|
||
// ============================================================
|
||
|
||
interface FilterTableInfo {
|
||
tableName: string;
|
||
tableLabel?: string;
|
||
filterColumns?: string[];
|
||
joinColumnRefs?: Array<{
|
||
column: string;
|
||
refTable: string;
|
||
refTableLabel?: string;
|
||
refColumn: string;
|
||
}>;
|
||
}
|
||
|
||
interface FieldMappingInfo {
|
||
targetField: string;
|
||
sourceField: string;
|
||
sourceTable?: string;
|
||
sourceDisplayName?: string;
|
||
componentType?: string;
|
||
}
|
||
|
||
interface ScreenSettingModalProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
screenId: number;
|
||
screenName: string;
|
||
groupId?: number;
|
||
companyCode?: string; // 프리뷰용 회사 코드
|
||
mainTable?: string;
|
||
mainTableLabel?: string;
|
||
filterTables?: FilterTableInfo[];
|
||
fieldMappings?: FieldMappingInfo[];
|
||
componentCount?: number;
|
||
onSaveSuccess?: () => void;
|
||
}
|
||
|
||
// 검색 가능한 Select 컴포넌트
|
||
interface SearchableSelectProps {
|
||
value: string;
|
||
onValueChange: (value: string) => void;
|
||
options: Array<{ value: string; label: string; description?: string }>;
|
||
placeholder?: string;
|
||
disabled?: boolean;
|
||
className?: string;
|
||
}
|
||
|
||
function SearchableSelect({
|
||
value,
|
||
onValueChange,
|
||
options,
|
||
placeholder = "선택...",
|
||
disabled = false,
|
||
className,
|
||
}: SearchableSelectProps) {
|
||
const [open, setOpen] = useState(false);
|
||
|
||
const selectedOption = options.find((opt) => opt.value === value);
|
||
|
||
return (
|
||
<Popover open={open} onOpenChange={setOpen}>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
role="combobox"
|
||
aria-expanded={open}
|
||
disabled={disabled}
|
||
className={cn("h-8 w-full justify-between text-xs", className)}
|
||
>
|
||
{selectedOption ? (
|
||
<span className="truncate">{selectedOption.label}</span>
|
||
) : (
|
||
<span className="text-muted-foreground">{placeholder}</span>
|
||
)}
|
||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
className="p-0"
|
||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||
align="start"
|
||
>
|
||
<Command>
|
||
<CommandInput placeholder="검색..." className="text-xs" />
|
||
<CommandList>
|
||
<CommandEmpty className="py-2 text-center text-xs">
|
||
결과 없음
|
||
</CommandEmpty>
|
||
<CommandGroup>
|
||
{options.map((option) => (
|
||
<CommandItem
|
||
key={option.value}
|
||
value={option.value}
|
||
onSelect={() => {
|
||
onValueChange(option.value);
|
||
setOpen(false);
|
||
}}
|
||
className="text-xs"
|
||
>
|
||
<Check
|
||
className={cn(
|
||
"mr-2 h-4 w-4",
|
||
value === option.value ? "opacity-100" : "opacity-0"
|
||
)}
|
||
/>
|
||
<div className="flex flex-col">
|
||
<span>{option.label}</span>
|
||
{option.description && (
|
||
<span className="text-muted-foreground text-[10px]">
|
||
{option.description}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</CommandItem>
|
||
))}
|
||
</CommandGroup>
|
||
</CommandList>
|
||
</Command>
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 메인 모달 컴포넌트
|
||
// ============================================================
|
||
|
||
export function ScreenSettingModal({
|
||
isOpen,
|
||
onClose,
|
||
screenId,
|
||
screenName,
|
||
groupId,
|
||
companyCode,
|
||
mainTable,
|
||
mainTableLabel,
|
||
filterTables = [],
|
||
fieldMappings = [],
|
||
componentCount = 0,
|
||
onSaveSuccess,
|
||
}: ScreenSettingModalProps) {
|
||
const [activeTab, setActiveTab] = useState("overview");
|
||
const [loading, setLoading] = useState(false);
|
||
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
|
||
const [layoutItems, setLayoutItems] = useState<LayoutItem[]>([]);
|
||
|
||
// 데이터 로드
|
||
const loadData = useCallback(async () => {
|
||
if (!screenId) return;
|
||
|
||
setLoading(true);
|
||
try {
|
||
// 1. 해당 화면에서 시작하는 데이터 흐름 로드
|
||
const flowsResponse = await getDataFlows({ sourceScreenId: screenId });
|
||
if (flowsResponse.success && flowsResponse.data) {
|
||
setDataFlows(flowsResponse.data);
|
||
}
|
||
|
||
// 2. 화면 레이아웃 요약 정보 로드 (컴포넌트 컬럼 정보 포함)
|
||
const layoutResponse = await getMultipleScreenLayoutSummary([screenId]);
|
||
if (layoutResponse.success && layoutResponse.data) {
|
||
const screenLayout = layoutResponse.data[screenId];
|
||
setLayoutItems(screenLayout?.layoutItems || []);
|
||
}
|
||
} catch (error) {
|
||
console.error("데이터 로드 실패:", error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [screenId]);
|
||
|
||
useEffect(() => {
|
||
if (isOpen && screenId) {
|
||
loadData();
|
||
}
|
||
}, [isOpen, screenId, loadData]);
|
||
|
||
// 새로고침
|
||
const handleRefresh = () => {
|
||
loadData();
|
||
toast.success("새로고침 완료");
|
||
};
|
||
|
||
return (
|
||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||
<DialogContent className="flex h-[85vh] max-h-[900px] w-[95vw] max-w-[1200px] flex-col">
|
||
<DialogHeader className="flex-shrink-0">
|
||
<DialogTitle className="flex items-center gap-2 text-lg">
|
||
<Settings2 className="h-5 w-5 text-blue-500" />
|
||
화면 설정: {screenName}
|
||
</DialogTitle>
|
||
<DialogDescription className="text-sm">
|
||
화면의 필드 매핑, 테이블 연결, 데이터 흐름을 확인하고 설정합니다.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<Tabs
|
||
value={activeTab}
|
||
onValueChange={setActiveTab}
|
||
className="flex min-h-0 flex-1 flex-col"
|
||
>
|
||
<div className="flex flex-shrink-0 items-center justify-between border-b pb-2">
|
||
<TabsList className="h-9">
|
||
<TabsTrigger value="overview" className="gap-1 text-xs">
|
||
<Database className="h-3.5 w-3.5" />
|
||
화면 개요
|
||
</TabsTrigger>
|
||
<TabsTrigger value="field-mapping" className="gap-1 text-xs">
|
||
<Columns3 className="h-3.5 w-3.5" />
|
||
필드 매핑
|
||
</TabsTrigger>
|
||
<TabsTrigger value="data-flow" className="gap-1 text-xs">
|
||
<GitBranch className="h-3.5 w-3.5" />
|
||
데이터 흐름
|
||
</TabsTrigger>
|
||
<TabsTrigger value="preview" className="gap-1 text-xs">
|
||
<Eye className="h-3.5 w-3.5" />
|
||
화면 프리뷰
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={handleRefresh}
|
||
className="gap-1"
|
||
>
|
||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||
새로고침
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 탭 1: 화면 개요 */}
|
||
<TabsContent value="overview" className="mt-0 min-h-0 flex-1 overflow-auto p-4">
|
||
<OverviewTab
|
||
screenId={screenId}
|
||
screenName={screenName}
|
||
mainTable={mainTable}
|
||
mainTableLabel={mainTableLabel}
|
||
filterTables={filterTables}
|
||
fieldMappings={fieldMappings}
|
||
componentCount={componentCount}
|
||
dataFlows={dataFlows}
|
||
layoutItems={layoutItems}
|
||
loading={loading}
|
||
/>
|
||
</TabsContent>
|
||
|
||
{/* 탭 2: 필드 매핑 */}
|
||
<TabsContent value="field-mapping" className="mt-0 min-h-0 flex-1 overflow-auto p-4">
|
||
<FieldMappingTab
|
||
screenId={screenId}
|
||
mainTable={mainTable}
|
||
fieldMappings={fieldMappings}
|
||
layoutItems={layoutItems}
|
||
loading={loading}
|
||
/>
|
||
</TabsContent>
|
||
|
||
{/* 탭 3: 데이터 흐름 */}
|
||
<TabsContent value="data-flow" className="mt-0 min-h-0 flex-1 overflow-auto p-4">
|
||
<DataFlowTab
|
||
screenId={screenId}
|
||
groupId={groupId}
|
||
dataFlows={dataFlows}
|
||
loading={loading}
|
||
onReload={loadData}
|
||
onSaveSuccess={onSaveSuccess}
|
||
/>
|
||
</TabsContent>
|
||
|
||
{/* 탭 4: 화면 프리뷰 */}
|
||
<TabsContent value="preview" className="mt-0 min-h-0 flex-1 overflow-hidden p-4">
|
||
<PreviewTab screenId={screenId} screenName={screenName} companyCode={companyCode} />
|
||
</TabsContent>
|
||
</Tabs>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 필터 테이블 아코디언 컴포넌트
|
||
// ============================================================
|
||
|
||
interface FilterTableAccordionProps {
|
||
filterTable: FilterTableInfo;
|
||
mainTable?: string;
|
||
}
|
||
|
||
function FilterTableAccordion({ filterTable: ft, mainTable }: FilterTableAccordionProps) {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [columns, setColumns] = useState<ColumnTypeInfo[]>([]);
|
||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||
|
||
const hasJoinRefs = ft.joinColumnRefs && ft.joinColumnRefs.length > 0;
|
||
const hasFilterColumns = ft.filterColumns && ft.filterColumns.length > 0;
|
||
|
||
// 아코디언 열릴 때 테이블 컬럼 로드
|
||
const handleToggle = async () => {
|
||
const newIsOpen = !isOpen;
|
||
setIsOpen(newIsOpen);
|
||
|
||
// 처음 열릴 때 컬럼 로드
|
||
if (newIsOpen && columns.length === 0 && ft.tableName) {
|
||
setLoadingColumns(true);
|
||
try {
|
||
const result = await tableManagementApi.getColumnList(ft.tableName);
|
||
if (result.success && result.data && result.data.columns) {
|
||
setColumns(result.data.columns);
|
||
}
|
||
} catch (error) {
|
||
console.error("테이블 컬럼 로드 실패:", error);
|
||
} finally {
|
||
setLoadingColumns(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="rounded-lg border bg-purple-50/30 overflow-hidden">
|
||
{/* 헤더 - 클릭하면 펼쳐짐 */}
|
||
<button
|
||
type="button"
|
||
onClick={handleToggle}
|
||
className="w-full flex items-center gap-3 p-3 hover:bg-purple-50/50 transition-colors text-left"
|
||
>
|
||
{/* 펼침/접힘 아이콘 */}
|
||
{isOpen ? (
|
||
<ChevronDown className="h-4 w-4 text-purple-500 flex-shrink-0" />
|
||
) : (
|
||
<ChevronRight className="h-4 w-4 text-purple-500 flex-shrink-0" />
|
||
)}
|
||
|
||
<Filter className="h-4 w-4 text-purple-500 flex-shrink-0" />
|
||
|
||
<div className="flex-1 min-w-0">
|
||
<div className="font-medium truncate">{ft.tableLabel || ft.tableName}</div>
|
||
{ft.tableLabel && ft.tableName !== ft.tableLabel && (
|
||
<div className="text-xs text-muted-foreground truncate">{ft.tableName}</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 필터 배지 - 보라색 */}
|
||
<Badge variant="outline" className="bg-purple-100 text-purple-700 text-xs flex-shrink-0">
|
||
필터
|
||
</Badge>
|
||
|
||
{/* 요약 정보 */}
|
||
<div className="text-xs text-muted-foreground flex-shrink-0">
|
||
{hasFilterColumns && `${ft.filterColumns!.length}개 필터`}
|
||
{hasJoinRefs && hasFilterColumns && " / "}
|
||
{hasJoinRefs && `${ft.joinColumnRefs!.length}개 조인`}
|
||
</div>
|
||
</button>
|
||
|
||
{/* 펼쳐진 내용 */}
|
||
{isOpen && (
|
||
<div className="border-t border-purple-100 p-3 space-y-3 bg-white/50">
|
||
{/* 필터 키 설명 */}
|
||
<div className="text-xs text-muted-foreground">
|
||
<span className="font-medium text-purple-700">{ft.tableLabel || ft.tableName}</span>의 데이터를 기준으로 필터링됩니다.
|
||
</div>
|
||
|
||
{/* 테이블 컬럼 정보 */}
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-1 text-xs font-medium text-gray-700">
|
||
<Table2 className="h-3 w-3" />
|
||
테이블 컬럼 ({loadingColumns ? "로딩중..." : `${columns.length}개`})
|
||
</div>
|
||
{loadingColumns ? (
|
||
<div className="flex items-center justify-center py-2">
|
||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : columns.length > 0 ? (
|
||
<div className="grid grid-cols-2 gap-1 max-h-32 overflow-y-auto">
|
||
{columns.slice(0, 10).map((col, cIdx) => (
|
||
<div
|
||
key={cIdx}
|
||
className="flex items-center gap-1 text-xs bg-gray-50 rounded px-2 py-1"
|
||
>
|
||
<span className="font-medium truncate">{col.displayName || col.columnName}</span>
|
||
<span className="text-muted-foreground text-[10px] truncate">
|
||
({col.dataType})
|
||
</span>
|
||
</div>
|
||
))}
|
||
{columns.length > 10 && (
|
||
<div className="text-xs text-muted-foreground px-2 py-1">
|
||
+{columns.length - 10}개 더
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="text-xs text-muted-foreground text-center py-1">
|
||
컬럼 정보 없음
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 필터 컬럼 매핑 */}
|
||
{hasFilterColumns && (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-1 text-xs font-medium text-purple-700">
|
||
<Filter className="h-3 w-3" />
|
||
필터 키 매핑
|
||
</div>
|
||
<div className="space-y-1">
|
||
{ft.filterColumns!.map((col, cIdx) => (
|
||
<div
|
||
key={cIdx}
|
||
className="flex items-center gap-2 text-xs bg-purple-50 rounded-md p-2"
|
||
>
|
||
<span className="rounded bg-white px-2 py-0.5 border border-purple-200 font-medium">
|
||
{mainTable}.{col}
|
||
</span>
|
||
<ArrowRight className="h-3 w-3 text-purple-400" />
|
||
<span className="rounded bg-purple-100 px-2 py-0.5 border border-purple-200">
|
||
{ft.tableLabel || ft.tableName}.{col}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 조인 관계 */}
|
||
{hasJoinRefs && (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-1 text-xs font-medium text-orange-700">
|
||
<Link2 className="h-3 w-3" />
|
||
조인 관계 ({ft.joinColumnRefs!.length}개)
|
||
</div>
|
||
<div className="space-y-1">
|
||
{ft.joinColumnRefs!.map((join, jIdx) => (
|
||
<div
|
||
key={jIdx}
|
||
className="flex items-center gap-2 text-xs bg-orange-50 rounded-md p-2"
|
||
>
|
||
<span className="rounded bg-white px-2 py-0.5 border border-orange-200 font-medium">
|
||
{ft.tableLabel || ft.tableName}.{join.column}
|
||
</span>
|
||
<ArrowRight className="h-3 w-3 text-orange-400" />
|
||
<span className="rounded bg-orange-100 px-2 py-0.5 border border-orange-200">
|
||
{join.refTableLabel || join.refTable}.{join.refColumn}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 탭 1: 화면 개요
|
||
// ============================================================
|
||
|
||
interface OverviewTabProps {
|
||
screenId: number;
|
||
screenName: string;
|
||
mainTable?: string;
|
||
mainTableLabel?: string;
|
||
filterTables: FilterTableInfo[];
|
||
fieldMappings: FieldMappingInfo[];
|
||
componentCount: number;
|
||
dataFlows: DataFlow[];
|
||
layoutItems: LayoutItem[]; // 컴포넌트 컬럼 정보 추가
|
||
loading: boolean;
|
||
}
|
||
|
||
function OverviewTab({
|
||
screenId,
|
||
screenName,
|
||
mainTable,
|
||
mainTableLabel,
|
||
filterTables,
|
||
fieldMappings,
|
||
componentCount,
|
||
dataFlows,
|
||
layoutItems,
|
||
loading,
|
||
}: OverviewTabProps) {
|
||
// 통계 계산 (layoutItems의 컬럼 수도 포함)
|
||
const stats = useMemo(() => {
|
||
const totalJoins = filterTables.reduce(
|
||
(sum, ft) => sum + (ft.joinColumnRefs?.length || 0),
|
||
0
|
||
);
|
||
const totalFilters = filterTables.reduce(
|
||
(sum, ft) => sum + (ft.filterColumns?.length || 0),
|
||
0
|
||
);
|
||
|
||
// layoutItems에서 사용하는 컬럼 수 계산
|
||
const layoutColumnsSet = new Set<string>();
|
||
layoutItems.forEach((item) => {
|
||
if (item.usedColumns) {
|
||
item.usedColumns.forEach((col) => layoutColumnsSet.add(col));
|
||
}
|
||
});
|
||
const layoutColumnCount = layoutColumnsSet.size;
|
||
|
||
return {
|
||
tableCount: 1 + filterTables.length, // 메인 + 필터
|
||
fieldCount: layoutColumnCount > 0 ? layoutColumnCount : fieldMappings.length,
|
||
joinCount: totalJoins,
|
||
filterCount: totalFilters,
|
||
flowCount: dataFlows.length,
|
||
};
|
||
}, [filterTables, fieldMappings, dataFlows, layoutItems]);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* 기본 정보 카드 */}
|
||
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
|
||
<div className="rounded-lg border bg-blue-50 p-4">
|
||
<div className="text-2xl font-bold text-blue-600">{stats.tableCount}</div>
|
||
<div className="text-xs text-blue-700">연결된 테이블</div>
|
||
</div>
|
||
<div className="rounded-lg border bg-purple-50 p-4">
|
||
<div className="text-2xl font-bold text-purple-600">{stats.fieldCount}</div>
|
||
<div className="text-xs text-purple-700">필드 매핑</div>
|
||
</div>
|
||
<div className="rounded-lg border bg-orange-50 p-4">
|
||
<div className="text-2xl font-bold text-orange-600">{stats.joinCount}</div>
|
||
<div className="text-xs text-orange-700">조인 설정</div>
|
||
</div>
|
||
<div className="rounded-lg border bg-green-50 p-4">
|
||
<div className="text-2xl font-bold text-green-600">{stats.filterCount}</div>
|
||
<div className="text-xs text-green-700">필터 컬럼</div>
|
||
</div>
|
||
<div className="rounded-lg border bg-pink-50 p-4">
|
||
<div className="text-2xl font-bold text-pink-600">{stats.flowCount}</div>
|
||
<div className="text-xs text-pink-700">데이터 흐름</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 메인 테이블 */}
|
||
<div className="space-y-2">
|
||
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||
<Database className="h-4 w-4 text-blue-500" />
|
||
메인 테이블
|
||
</h3>
|
||
{mainTable ? (
|
||
<div className="flex items-center gap-3 rounded-lg border bg-blue-50/50 p-3">
|
||
<Table2 className="h-5 w-5 text-blue-500" />
|
||
<div>
|
||
<div className="font-medium">{mainTableLabel || mainTable}</div>
|
||
{mainTableLabel && mainTable !== mainTableLabel && (
|
||
<div className="text-xs text-muted-foreground">{mainTable}</div>
|
||
)}
|
||
</div>
|
||
<Badge variant="outline" className="ml-auto bg-blue-100 text-blue-700">
|
||
메인
|
||
</Badge>
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||
메인 테이블이 설정되지 않았습니다.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 연결된 필터 테이블 (아코디언 형식) */}
|
||
<div className="space-y-2">
|
||
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||
<Link2 className="h-4 w-4 text-purple-500" />
|
||
필터 테이블 ({filterTables.length}개)
|
||
</h3>
|
||
{filterTables.length > 0 ? (
|
||
<div className="space-y-2">
|
||
{filterTables.map((ft, idx) => (
|
||
<FilterTableAccordion
|
||
key={`${ft.tableName}-${idx}`}
|
||
filterTable={ft}
|
||
mainTable={mainTable}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||
연결된 필터 테이블이 없습니다.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 데이터 흐름 요약 */}
|
||
<div className="space-y-2">
|
||
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||
<GitBranch className="h-4 w-4 text-pink-500" />
|
||
데이터 흐름 ({dataFlows.length}개)
|
||
</h3>
|
||
{dataFlows.length > 0 ? (
|
||
<div className="space-y-2">
|
||
{dataFlows.slice(0, 3).map((flow) => (
|
||
<div
|
||
key={flow.id}
|
||
className="flex items-center gap-2 rounded-lg border bg-pink-50/50 p-3 text-sm"
|
||
>
|
||
<Badge variant="outline" className="bg-pink-100 text-pink-700">
|
||
{flow.flow_type}
|
||
</Badge>
|
||
<span className="flex-1">{flow.description || "설명 없음"}</span>
|
||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||
<span className="text-muted-foreground">화면 {flow.target_screen_id}</span>
|
||
</div>
|
||
))}
|
||
{dataFlows.length > 3 && (
|
||
<div className="text-center text-xs text-muted-foreground">
|
||
+{dataFlows.length - 3}개 더 있음
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||
설정된 데이터 흐름이 없습니다.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 탭 2: 필드 매핑
|
||
// ============================================================
|
||
|
||
interface FieldMappingTabProps {
|
||
screenId: number;
|
||
mainTable?: string;
|
||
fieldMappings: FieldMappingInfo[];
|
||
layoutItems: LayoutItem[];
|
||
loading: boolean;
|
||
}
|
||
|
||
function FieldMappingTab({
|
||
screenId,
|
||
mainTable,
|
||
fieldMappings,
|
||
layoutItems,
|
||
loading,
|
||
}: FieldMappingTabProps) {
|
||
// 화면 컴포넌트에서 사용하는 컬럼 정보 추출
|
||
const 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]);
|
||
|
||
// 컴포넌트 타입별 그룹핑 (기존 fieldMappings용)
|
||
const groupedMappings = useMemo(() => {
|
||
const grouped: Record<string, FieldMappingInfo[]> = {};
|
||
|
||
fieldMappings.forEach((mapping) => {
|
||
const type = mapping.componentType || "기타";
|
||
if (!grouped[type]) {
|
||
grouped[type] = [];
|
||
}
|
||
grouped[type].push(mapping);
|
||
});
|
||
|
||
return grouped;
|
||
}, [fieldMappings]);
|
||
|
||
const componentTypes = Object.keys(groupedMappings);
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex h-64 items-center justify-center">
|
||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* 화면 컴포넌트별 컬럼 사용 현황 */}
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h3 className="text-sm font-semibold">화면 컴포넌트별 컬럼 사용 현황</h3>
|
||
<p className="text-xs text-muted-foreground">
|
||
각 컴포넌트에서 사용하는 테이블 컬럼을 확인합니다.
|
||
</p>
|
||
</div>
|
||
<Badge variant="outline" className="text-xs">
|
||
총 {totalColumns}개 컬럼
|
||
</Badge>
|
||
</div>
|
||
|
||
{componentColumns.length === 0 ? (
|
||
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
|
||
<Columns3 className="mx-auto mb-2 h-8 w-8" />
|
||
<p className="text-sm">화면 컴포넌트에서 사용하는 컬럼 정보가 없습니다.</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{componentColumns.map((comp, idx) => (
|
||
<div
|
||
key={idx}
|
||
className="rounded-lg border bg-gray-50 p-3"
|
||
>
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Table2 className="h-4 w-4 text-blue-500" />
|
||
<span className="text-sm font-medium">
|
||
{comp.componentLabel || comp.componentKind}
|
||
</span>
|
||
<Badge variant="outline" className="text-[10px]">
|
||
{comp.componentKind}
|
||
</Badge>
|
||
</div>
|
||
<Badge variant="outline" className="text-xs">
|
||
{comp.columns.length}개 컬럼
|
||
</Badge>
|
||
</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{comp.columns.map((col, cIdx) => {
|
||
const isJoinColumn = comp.joinColumns.includes(col);
|
||
return (
|
||
<Badge
|
||
key={cIdx}
|
||
variant="outline"
|
||
className={cn(
|
||
"text-[11px]",
|
||
isJoinColumn
|
||
? "bg-orange-100 text-orange-700"
|
||
: "bg-white"
|
||
)}
|
||
>
|
||
{col}
|
||
{isJoinColumn && (
|
||
<Link2 className="ml-1 h-2.5 w-2.5" />
|
||
)}
|
||
</Badge>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 서브 테이블 연결 관계 (기존 fieldMappings) */}
|
||
{fieldMappings.length > 0 && (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h3 className="text-sm font-semibold">서브 테이블 연결 관계</h3>
|
||
<p className="text-xs text-muted-foreground">
|
||
메인 테이블과 서브 테이블 간의 필드 연결 관계입니다.
|
||
</p>
|
||
</div>
|
||
<Badge variant="outline" className="text-xs">
|
||
총 {fieldMappings.length}개 연결
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="rounded-lg border">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-[50px] text-xs">#</TableHead>
|
||
<TableHead className="text-xs">메인 테이블 컬럼</TableHead>
|
||
<TableHead className="w-[60px] text-center text-xs"></TableHead>
|
||
<TableHead className="text-xs">서브 테이블</TableHead>
|
||
<TableHead className="text-xs">서브 테이블 컬럼</TableHead>
|
||
<TableHead className="text-xs">연결 타입</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{fieldMappings.map((mapping, idx) => (
|
||
<TableRow key={idx}>
|
||
<TableCell className="text-xs text-muted-foreground">
|
||
{idx + 1}
|
||
</TableCell>
|
||
<TableCell className="text-xs font-medium">
|
||
<Badge variant="outline" className="bg-blue-50 text-blue-700">
|
||
{mainTable}.{mapping.targetField}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-center">
|
||
<ArrowRight className="mx-auto h-3 w-3 text-gray-400" />
|
||
</TableCell>
|
||
<TableCell className="text-xs">
|
||
<Badge variant="outline" className="bg-purple-50 text-purple-700">
|
||
{mapping.sourceTable || "-"}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-xs">
|
||
<Badge variant="outline" className="bg-green-50 text-green-700">
|
||
{mapping.sourceField}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-xs text-muted-foreground">
|
||
{mapping.componentType || "-"}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
{/* 컴포넌트 타입별 요약 */}
|
||
{componentTypes.length > 0 && (
|
||
<div className="space-y-2">
|
||
<h4 className="text-xs font-medium text-muted-foreground">연결 타입별 분류</h4>
|
||
<div className="flex flex-wrap gap-2">
|
||
{componentTypes.map((type) => (
|
||
<Badge
|
||
key={type}
|
||
variant="outline"
|
||
className="gap-1 bg-gray-50"
|
||
>
|
||
{type}
|
||
<span className="rounded-full bg-gray-200 px-1.5 text-[10px]">
|
||
{groupedMappings[type].length}
|
||
</span>
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 탭 3: 데이터 흐름
|
||
// ============================================================
|
||
|
||
interface DataFlowTabProps {
|
||
screenId: number;
|
||
groupId?: number;
|
||
dataFlows: DataFlow[];
|
||
loading: boolean;
|
||
onReload: () => void;
|
||
onSaveSuccess?: () => void;
|
||
}
|
||
|
||
function DataFlowTab({
|
||
screenId,
|
||
groupId,
|
||
dataFlows,
|
||
loading,
|
||
onReload,
|
||
onSaveSuccess,
|
||
}: DataFlowTabProps) {
|
||
const [isEditing, setIsEditing] = useState(false);
|
||
const [editItem, setEditItem] = useState<DataFlow | null>(null);
|
||
const [formData, setFormData] = useState({
|
||
target_screen_id: "",
|
||
action_type: "navigate",
|
||
data_mapping: "",
|
||
flow_type: "forward",
|
||
description: "",
|
||
is_active: "Y",
|
||
});
|
||
|
||
// 폼 초기화
|
||
const resetForm = () => {
|
||
setFormData({
|
||
target_screen_id: "",
|
||
action_type: "navigate",
|
||
data_mapping: "",
|
||
flow_type: "forward",
|
||
description: "",
|
||
is_active: "Y",
|
||
});
|
||
setEditItem(null);
|
||
setIsEditing(false);
|
||
};
|
||
|
||
// 수정 모드
|
||
const handleEdit = (item: DataFlow) => {
|
||
setEditItem(item);
|
||
setFormData({
|
||
target_screen_id: String(item.target_screen_id),
|
||
action_type: item.action_type,
|
||
data_mapping: item.data_mapping || "",
|
||
flow_type: item.flow_type,
|
||
description: item.description || "",
|
||
is_active: item.is_active,
|
||
});
|
||
setIsEditing(true);
|
||
};
|
||
|
||
// 저장
|
||
const handleSave = async () => {
|
||
if (!formData.target_screen_id) {
|
||
toast.error("대상 화면을 선택해주세요.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const payload = {
|
||
source_screen_id: screenId,
|
||
target_screen_id: parseInt(formData.target_screen_id),
|
||
action_type: formData.action_type,
|
||
data_mapping: formData.data_mapping || null,
|
||
flow_type: formData.flow_type,
|
||
description: formData.description || null,
|
||
is_active: formData.is_active,
|
||
};
|
||
|
||
let response;
|
||
if (editItem) {
|
||
response = await updateDataFlow(editItem.id, payload);
|
||
} else {
|
||
response = await createDataFlow(payload);
|
||
}
|
||
|
||
if (response.success) {
|
||
toast.success(editItem ? "데이터 흐름이 수정되었습니다." : "데이터 흐름이 추가되었습니다.");
|
||
resetForm();
|
||
onReload();
|
||
onSaveSuccess?.();
|
||
} else {
|
||
toast.error(response.message || "저장에 실패했습니다.");
|
||
}
|
||
} catch (error) {
|
||
console.error("저장 오류:", error);
|
||
toast.error("저장 중 오류가 발생했습니다.");
|
||
}
|
||
};
|
||
|
||
// 삭제
|
||
const handleDelete = async (id: number) => {
|
||
if (!confirm("정말로 삭제하시겠습니까?")) return;
|
||
|
||
try {
|
||
const response = await deleteDataFlow(id);
|
||
if (response.success) {
|
||
toast.success("데이터 흐름이 삭제되었습니다.");
|
||
onReload();
|
||
onSaveSuccess?.();
|
||
} else {
|
||
toast.error(response.message || "삭제에 실패했습니다.");
|
||
}
|
||
} catch (error) {
|
||
console.error("삭제 오류:", error);
|
||
toast.error("삭제 중 오류가 발생했습니다.");
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex h-64 items-center justify-center">
|
||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* 입력 폼 */}
|
||
<div className="space-y-3 rounded-lg bg-muted/50 p-4">
|
||
<div className="text-sm font-medium">
|
||
{isEditing ? "데이터 흐름 수정" : "새 데이터 흐름 추가"}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||
<div>
|
||
<Label className="text-xs">대상 화면 ID *</Label>
|
||
<Input
|
||
type="number"
|
||
value={formData.target_screen_id}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, target_screen_id: e.target.value })
|
||
}
|
||
placeholder="화면 ID"
|
||
className="h-8 text-xs"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">액션 타입</Label>
|
||
<SearchableSelect
|
||
value={formData.action_type}
|
||
onValueChange={(v) => setFormData({ ...formData, action_type: v })}
|
||
options={[
|
||
{ value: "navigate", label: "화면 이동" },
|
||
{ value: "modal", label: "모달 열기" },
|
||
{ value: "callback", label: "콜백" },
|
||
{ value: "refresh", label: "새로고침" },
|
||
]}
|
||
placeholder="액션 선택"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">흐름 타입</Label>
|
||
<SearchableSelect
|
||
value={formData.flow_type}
|
||
onValueChange={(v) => setFormData({ ...formData, flow_type: v })}
|
||
options={[
|
||
{ value: "forward", label: "전달 (Forward)" },
|
||
{ value: "return", label: "반환 (Return)" },
|
||
{ value: "broadcast", label: "브로드캐스트" },
|
||
]}
|
||
placeholder="흐름 선택"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-xs">설명</Label>
|
||
<Textarea
|
||
value={formData.description}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, description: e.target.value })
|
||
}
|
||
placeholder="데이터 흐름에 대한 설명"
|
||
className="h-16 resize-none text-xs"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-2">
|
||
{isEditing && (
|
||
<Button variant="outline" size="sm" onClick={resetForm}>
|
||
취소
|
||
</Button>
|
||
)}
|
||
<Button size="sm" onClick={handleSave} className="gap-1">
|
||
<Save className="h-4 w-4" />
|
||
{isEditing ? "수정" : "추가"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 목록 */}
|
||
<div className="rounded-lg border">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="text-xs">대상 화면</TableHead>
|
||
<TableHead className="text-xs">액션</TableHead>
|
||
<TableHead className="text-xs">흐름 타입</TableHead>
|
||
<TableHead className="text-xs">설명</TableHead>
|
||
<TableHead className="w-[100px] text-xs">작업</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{dataFlows.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell
|
||
colSpan={5}
|
||
className="py-8 text-center text-sm text-muted-foreground"
|
||
>
|
||
등록된 데이터 흐름이 없습니다.
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
dataFlows.map((flow) => (
|
||
<TableRow key={flow.id}>
|
||
<TableCell className="text-xs font-medium">
|
||
화면 {flow.target_screen_id}
|
||
</TableCell>
|
||
<TableCell className="text-xs">
|
||
<Badge variant="outline" className="text-xs">
|
||
{flow.action_type}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-xs">
|
||
<Badge
|
||
variant="outline"
|
||
className={cn(
|
||
"text-xs",
|
||
flow.flow_type === "forward" && "bg-blue-50 text-blue-700",
|
||
flow.flow_type === "return" && "bg-green-50 text-green-700",
|
||
flow.flow_type === "broadcast" && "bg-purple-50 text-purple-700"
|
||
)}
|
||
>
|
||
{flow.flow_type}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-xs text-muted-foreground">
|
||
{flow.description || "-"}
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7"
|
||
onClick={() => handleEdit(flow)}
|
||
>
|
||
<Pencil className="h-3 w-3" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7 text-destructive"
|
||
onClick={() => handleDelete(flow.id)}
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 탭 4: 화면 프리뷰 (iframe)
|
||
// ============================================================
|
||
|
||
interface PreviewTabProps {
|
||
screenId: number;
|
||
screenName: string;
|
||
companyCode?: string;
|
||
}
|
||
|
||
function PreviewTab({ screenId, screenName, companyCode }: PreviewTabProps) {
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// 화면 URL 생성 (preview=true로 사이드바 없이 화면만 표시, company_code 전달)
|
||
const previewUrl = useMemo(() => {
|
||
// 현재 호스트 기반으로 URL 생성
|
||
const params = new URLSearchParams({ preview: "true" });
|
||
// 프리뷰용 회사 코드 추가 (데이터 조회에 필요)
|
||
if (companyCode) {
|
||
params.set("company_code", companyCode);
|
||
}
|
||
if (typeof window !== "undefined") {
|
||
const baseUrl = window.location.origin;
|
||
return `${baseUrl}/screens/${screenId}?${params.toString()}`;
|
||
}
|
||
return `/screens/${screenId}?${params.toString()}`;
|
||
}, [screenId, companyCode]);
|
||
|
||
const handleIframeLoad = () => {
|
||
setLoading(false);
|
||
};
|
||
|
||
const handleIframeError = () => {
|
||
setLoading(false);
|
||
setError("화면을 불러오는데 실패했습니다.");
|
||
};
|
||
|
||
const openInNewTab = () => {
|
||
window.open(previewUrl, "_blank");
|
||
};
|
||
|
||
return (
|
||
<div className="flex h-full flex-col space-y-3">
|
||
{/* 상단 툴바 */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||
<span className="text-sm font-medium">화면 프리뷰</span>
|
||
<Badge variant="outline" className="text-xs">
|
||
Screen ID: {screenId}
|
||
</Badge>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
setLoading(true);
|
||
const iframe = document.getElementById("screen-preview-iframe") as HTMLIFrameElement;
|
||
if (iframe) {
|
||
iframe.src = iframe.src;
|
||
}
|
||
}}
|
||
className="gap-1"
|
||
>
|
||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||
새로고침
|
||
</Button>
|
||
<Button variant="outline" size="sm" onClick={openInNewTab} className="gap-1">
|
||
<ExternalLink className="h-4 w-4" />
|
||
새 탭에서 열기
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* iframe 영역 */}
|
||
<div className="relative flex-1 overflow-hidden rounded-lg border bg-gray-50">
|
||
{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>
|
||
) : (
|
||
<iframe
|
||
id="screen-preview-iframe"
|
||
src={previewUrl}
|
||
className="h-full w-full border-0"
|
||
onLoad={handleIframeLoad}
|
||
onError={handleIframeError}
|
||
title={`화면 프리뷰: ${screenName}`}
|
||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* 안내 메시지 */}
|
||
<div className="text-xs text-muted-foreground">
|
||
* 프리뷰는 실제 화면을 iframe으로 로드합니다. 로그인/권한에 따라 일부 기능이 제한될 수 있습니다.
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default ScreenSettingModal;
|
||
|