1074 lines
34 KiB
TypeScript
1074 lines
34 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,
|
|||
|
|
} from "lucide-react";
|
|||
|
|
import {
|
|||
|
|
getDataFlows,
|
|||
|
|
createDataFlow,
|
|||
|
|
updateDataFlow,
|
|||
|
|
deleteDataFlow,
|
|||
|
|
DataFlow,
|
|||
|
|
} 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;
|
|||
|
|
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,
|
|||
|
|
mainTable,
|
|||
|
|
mainTableLabel,
|
|||
|
|
filterTables = [],
|
|||
|
|
fieldMappings = [],
|
|||
|
|
componentCount = 0,
|
|||
|
|
onSaveSuccess,
|
|||
|
|
}: ScreenSettingModalProps) {
|
|||
|
|
const [activeTab, setActiveTab] = useState("overview");
|
|||
|
|
const [loading, setLoading] = useState(false);
|
|||
|
|
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
|
|||
|
|
|
|||
|
|
// 데이터 로드
|
|||
|
|
const loadData = useCallback(async () => {
|
|||
|
|
if (!screenId) return;
|
|||
|
|
|
|||
|
|
setLoading(true);
|
|||
|
|
try {
|
|||
|
|
// 데이터 흐름 로드
|
|||
|
|
const flowsResponse = await getDataFlows(screenId);
|
|||
|
|
if (flowsResponse.success && flowsResponse.data) {
|
|||
|
|
setDataFlows(flowsResponse.data);
|
|||
|
|
}
|
|||
|
|
} 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}
|
|||
|
|
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}
|
|||
|
|
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} />
|
|||
|
|
</TabsContent>
|
|||
|
|
</Tabs>
|
|||
|
|
</DialogContent>
|
|||
|
|
</Dialog>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 탭 1: 화면 개요
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
interface OverviewTabProps {
|
|||
|
|
screenId: number;
|
|||
|
|
screenName: string;
|
|||
|
|
mainTable?: string;
|
|||
|
|
mainTableLabel?: string;
|
|||
|
|
filterTables: FilterTableInfo[];
|
|||
|
|
fieldMappings: FieldMappingInfo[];
|
|||
|
|
componentCount: number;
|
|||
|
|
dataFlows: DataFlow[];
|
|||
|
|
loading: boolean;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function OverviewTab({
|
|||
|
|
screenId,
|
|||
|
|
screenName,
|
|||
|
|
mainTable,
|
|||
|
|
mainTableLabel,
|
|||
|
|
filterTables,
|
|||
|
|
fieldMappings,
|
|||
|
|
componentCount,
|
|||
|
|
dataFlows,
|
|||
|
|
loading,
|
|||
|
|
}: OverviewTabProps) {
|
|||
|
|
// 통계 계산
|
|||
|
|
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
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
tableCount: 1 + filterTables.length, // 메인 + 필터
|
|||
|
|
fieldCount: fieldMappings.length,
|
|||
|
|
joinCount: totalJoins,
|
|||
|
|
filterCount: totalFilters,
|
|||
|
|
flowCount: dataFlows.length,
|
|||
|
|
};
|
|||
|
|
}, [filterTables, fieldMappings, dataFlows]);
|
|||
|
|
|
|||
|
|
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) => (
|
|||
|
|
<div
|
|||
|
|
key={`${ft.tableName}-${idx}`}
|
|||
|
|
className="rounded-lg border bg-purple-50/50 p-3"
|
|||
|
|
>
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<Table2 className="h-5 w-5 text-purple-500" />
|
|||
|
|
<div className="flex-1">
|
|||
|
|
<div className="font-medium">{ft.tableLabel || ft.tableName}</div>
|
|||
|
|
{ft.tableLabel && ft.tableName !== ft.tableLabel && (
|
|||
|
|
<div className="text-xs text-muted-foreground">{ft.tableName}</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<Badge variant="outline" className="bg-purple-100 text-purple-700">
|
|||
|
|
필터
|
|||
|
|
</Badge>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 조인 정보 */}
|
|||
|
|
{ft.joinColumnRefs && ft.joinColumnRefs.length > 0 && (
|
|||
|
|
<div className="mt-2 space-y-1 border-t pt-2">
|
|||
|
|
<div className="text-xs font-medium text-orange-700">조인 설정:</div>
|
|||
|
|
{ft.joinColumnRefs.map((join, jIdx) => (
|
|||
|
|
<div
|
|||
|
|
key={jIdx}
|
|||
|
|
className="flex items-center gap-2 text-xs text-muted-foreground"
|
|||
|
|
>
|
|||
|
|
<span className="rounded bg-orange-100 px-1.5 py-0.5">
|
|||
|
|
{join.column}
|
|||
|
|
</span>
|
|||
|
|
<ArrowRight className="h-3 w-3" />
|
|||
|
|
<span className="rounded bg-green-100 px-1.5 py-0.5">
|
|||
|
|
{join.refTableLabel || join.refTable}.{join.refColumn}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 필터 컬럼 */}
|
|||
|
|
{ft.filterColumns && ft.filterColumns.length > 0 && (
|
|||
|
|
<div className="mt-2 border-t pt-2">
|
|||
|
|
<div className="text-xs font-medium text-green-700">필터 컬럼:</div>
|
|||
|
|
<div className="mt-1 flex flex-wrap gap-1">
|
|||
|
|
{ft.filterColumns.map((col, cIdx) => (
|
|||
|
|
<Badge key={cIdx} variant="outline" className="bg-green-50 text-xs">
|
|||
|
|
{col}
|
|||
|
|
</Badge>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</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[];
|
|||
|
|
loading: boolean;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function FieldMappingTab({
|
|||
|
|
screenId,
|
|||
|
|
mainTable,
|
|||
|
|
fieldMappings,
|
|||
|
|
loading,
|
|||
|
|
}: FieldMappingTabProps) {
|
|||
|
|
// 컴포넌트 타입별 그룹핑
|
|||
|
|
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-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>
|
|||
|
|
|
|||
|
|
{fieldMappings.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="rounded-lg border">
|
|||
|
|
<Table>
|
|||
|
|
<TableHeader>
|
|||
|
|
<TableRow>
|
|||
|
|
<TableHead className="w-[50px] text-xs">#</TableHead>
|
|||
|
|
<TableHead className="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">
|
|||
|
|
{mapping.targetField}
|
|||
|
|
</TableCell>
|
|||
|
|
<TableCell className="text-xs">
|
|||
|
|
<Badge variant="outline" className="bg-blue-50 text-blue-700">
|
|||
|
|
{mapping.sourceTable || mainTable || "-"}
|
|||
|
|
</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="mt-4 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>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 탭 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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function PreviewTab({ screenId, screenName }: PreviewTabProps) {
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [error, setError] = useState<string | null>(null);
|
|||
|
|
|
|||
|
|
// 화면 URL 생성
|
|||
|
|
const previewUrl = useMemo(() => {
|
|||
|
|
// 현재 호스트 기반으로 URL 생성
|
|||
|
|
if (typeof window !== "undefined") {
|
|||
|
|
const baseUrl = window.location.origin;
|
|||
|
|
return `${baseUrl}/screens/${screenId}`;
|
|||
|
|
}
|
|||
|
|
return `/screens/${screenId}`;
|
|||
|
|
}, [screenId]);
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
|