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

1343 lines
45 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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