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

1074 lines
34 KiB
TypeScript
Raw Normal View History

"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;