873 lines
44 KiB
TypeScript
873 lines
44 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import React from "react";
|
||
|
|
import { Label } from "@/components/ui/label";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
|
|
import { Switch } from "@/components/ui/switch";
|
||
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import { QuickInsertConfigSection } from "../QuickInsertConfigSection";
|
||
|
|
import { ComponentData } from "@/types/screen";
|
||
|
|
|
||
|
|
export interface DataTabProps {
|
||
|
|
config: any;
|
||
|
|
onChange: (key: string, value: any) => void;
|
||
|
|
component: ComponentData;
|
||
|
|
allComponents: ComponentData[];
|
||
|
|
currentTableName?: string;
|
||
|
|
availableTables: Array<{ name: string; label: string }>;
|
||
|
|
mappingTargetColumns: Array<{ name: string; label: string }>;
|
||
|
|
mappingSourceColumnsMap: Record<string, Array<{ name: string; label: string }>>;
|
||
|
|
currentTableColumns: Array<{ name: string; label: string }>;
|
||
|
|
mappingSourcePopoverOpen: Record<string, boolean>;
|
||
|
|
setMappingSourcePopoverOpen: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||
|
|
mappingTargetPopoverOpen: Record<string, boolean>;
|
||
|
|
setMappingTargetPopoverOpen: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||
|
|
activeMappingGroupIndex: number;
|
||
|
|
setActiveMappingGroupIndex: React.Dispatch<React.SetStateAction<number>>;
|
||
|
|
loadMappingColumns: (tableName: string) => Promise<Array<{ name: string; label: string }>>;
|
||
|
|
setMappingSourceColumnsMap: React.Dispatch<
|
||
|
|
React.SetStateAction<Record<string, Array<{ name: string; label: string }>>>
|
||
|
|
>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const DataTab: React.FC<DataTabProps> = ({
|
||
|
|
config,
|
||
|
|
onChange,
|
||
|
|
component,
|
||
|
|
allComponents,
|
||
|
|
currentTableName,
|
||
|
|
availableTables,
|
||
|
|
mappingTargetColumns,
|
||
|
|
mappingSourceColumnsMap,
|
||
|
|
currentTableColumns,
|
||
|
|
mappingSourcePopoverOpen,
|
||
|
|
setMappingSourcePopoverOpen,
|
||
|
|
mappingTargetPopoverOpen,
|
||
|
|
setMappingTargetPopoverOpen,
|
||
|
|
activeMappingGroupIndex,
|
||
|
|
setActiveMappingGroupIndex,
|
||
|
|
loadMappingColumns,
|
||
|
|
setMappingSourceColumnsMap,
|
||
|
|
}) => {
|
||
|
|
const actionType = config.action?.type;
|
||
|
|
const onUpdateProperty = (path: string, value: any) => onChange(path, value);
|
||
|
|
|
||
|
|
if (actionType === "quickInsert") {
|
||
|
|
return (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<QuickInsertConfigSection
|
||
|
|
component={component}
|
||
|
|
onUpdateProperty={onUpdateProperty}
|
||
|
|
allComponents={allComponents}
|
||
|
|
currentTableName={currentTableName}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (actionType !== "transferData") {
|
||
|
|
return (
|
||
|
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||
|
|
데이터 전달 또는 즉시 저장 액션을 선택하면 설정할 수 있습니다.
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="bg-muted/50 space-y-4 rounded-lg border p-4">
|
||
|
|
<h4 className="text-foreground text-sm font-medium">데이터 전달 설정</h4>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<Label>
|
||
|
|
소스 컴포넌트 <span className="text-destructive">*</span>
|
||
|
|
</Label>
|
||
|
|
<Select
|
||
|
|
value={config.action?.dataTransfer?.sourceComponentId || ""}
|
||
|
|
onValueChange={(value) =>
|
||
|
|
onUpdateProperty("componentConfig.action.dataTransfer.sourceComponentId", value)
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<SelectTrigger className="h-8 text-xs">
|
||
|
|
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="__auto__">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<span className="text-xs font-medium">자동 탐색 (현재 활성 테이블)</span>
|
||
|
|
<span className="text-muted-foreground text-[10px]">(auto)</span>
|
||
|
|
</div>
|
||
|
|
</SelectItem>
|
||
|
|
{allComponents
|
||
|
|
.filter((comp: any) => {
|
||
|
|
const type = comp.componentType || comp.type || "";
|
||
|
|
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
||
|
|
type.includes(t),
|
||
|
|
);
|
||
|
|
})
|
||
|
|
.map((comp: any) => {
|
||
|
|
const compType = comp.componentType || comp.type || "unknown";
|
||
|
|
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||
|
|
const layerName = comp._layerName;
|
||
|
|
return (
|
||
|
|
<SelectItem key={comp.id} value={comp.id}>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<span className="text-xs font-medium">{compLabel}</span>
|
||
|
|
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||
|
|
{layerName && (
|
||
|
|
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||
|
|
{layerName}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</SelectItem>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
{allComponents.filter((comp: any) => {
|
||
|
|
const type = comp.componentType || comp.type || "";
|
||
|
|
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
||
|
|
type.includes(t),
|
||
|
|
);
|
||
|
|
}).length === 0 && (
|
||
|
|
<SelectItem value="__none__" disabled>
|
||
|
|
데이터 제공 가능한 컴포넌트가 없습니다
|
||
|
|
</SelectItem>
|
||
|
|
)}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
<p className="text-muted-foreground mt-1 text-xs">
|
||
|
|
레이어별로 다른 테이블이 있을 경우 "자동 탐색"을 선택하면 현재 활성화된 레이어의 테이블을 자동으로 사용합니다
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="target-type">
|
||
|
|
타겟 타입 <span className="text-destructive">*</span>
|
||
|
|
</Label>
|
||
|
|
<Select
|
||
|
|
value={config.action?.dataTransfer?.targetType || "component"}
|
||
|
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.targetType", value)}
|
||
|
|
>
|
||
|
|
<SelectTrigger className="h-8 text-xs">
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="component">같은 화면의 컴포넌트</SelectItem>
|
||
|
|
<SelectItem value="splitPanel">분할 패널 반대편 화면</SelectItem>
|
||
|
|
<SelectItem value="screen" disabled>
|
||
|
|
다른 화면 (구현 예정)
|
||
|
|
</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
{config.action?.dataTransfer?.targetType === "splitPanel" && (
|
||
|
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||
|
|
이 버튼이 분할 패널 내부에 있어야 합니다. 좌측 화면에서 우측으로, 또는 우측에서 좌측으로 데이터가 전달됩니다.
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{config.action?.dataTransfer?.targetType === "component" && (
|
||
|
|
<div>
|
||
|
|
<Label>
|
||
|
|
타겟 컴포넌트 <span className="text-destructive">*</span>
|
||
|
|
</Label>
|
||
|
|
<Select
|
||
|
|
value={config.action?.dataTransfer?.targetComponentId || ""}
|
||
|
|
onValueChange={(value) => {
|
||
|
|
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value);
|
||
|
|
const selectedComp = allComponents.find((c: any) => c.id === value);
|
||
|
|
if (selectedComp && (selectedComp as any)._layerId) {
|
||
|
|
onUpdateProperty(
|
||
|
|
"componentConfig.action.dataTransfer.targetLayerId",
|
||
|
|
(selectedComp as any)._layerId,
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
onUpdateProperty("componentConfig.action.dataTransfer.targetLayerId", undefined);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<SelectTrigger className="h-8 text-xs">
|
||
|
|
<SelectValue placeholder="데이터를 받을 컴포넌트 선택" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{allComponents
|
||
|
|
.filter((comp: any) => {
|
||
|
|
const type = comp.componentType || comp.type || "";
|
||
|
|
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||
|
|
(t) => type.includes(t),
|
||
|
|
);
|
||
|
|
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
||
|
|
})
|
||
|
|
.map((comp: any) => {
|
||
|
|
const compType = comp.componentType || comp.type || "unknown";
|
||
|
|
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||
|
|
const layerName = comp._layerName;
|
||
|
|
return (
|
||
|
|
<SelectItem key={comp.id} value={comp.id}>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<span className="text-xs font-medium">{compLabel}</span>
|
||
|
|
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||
|
|
{layerName && (
|
||
|
|
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||
|
|
{layerName}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</SelectItem>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
{allComponents.filter((comp: any) => {
|
||
|
|
const type = comp.componentType || comp.type || "";
|
||
|
|
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||
|
|
(t) => type.includes(t),
|
||
|
|
);
|
||
|
|
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
||
|
|
}).length === 0 && (
|
||
|
|
<SelectItem value="__none__" disabled>
|
||
|
|
데이터 수신 가능한 컴포넌트가 없습니다
|
||
|
|
</SelectItem>
|
||
|
|
)}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
<p className="text-muted-foreground mt-1 text-xs">테이블, 반복 필드 그룹 등 데이터를 받는 컴포넌트</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{config.action?.dataTransfer?.targetType === "splitPanel" && (
|
||
|
|
<div>
|
||
|
|
<Label>타겟 컴포넌트 ID (선택사항)</Label>
|
||
|
|
<Input
|
||
|
|
value={config.action?.dataTransfer?.targetComponentId || ""}
|
||
|
|
onChange={(e) =>
|
||
|
|
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value)
|
||
|
|
}
|
||
|
|
placeholder="비워두면 첫 번째 수신 가능 컴포넌트로 전달"
|
||
|
|
className="h-8 text-xs"
|
||
|
|
/>
|
||
|
|
<p className="text-muted-foreground mt-1 text-xs">
|
||
|
|
반대편 화면의 특정 컴포넌트 ID를 지정하거나, 비워두면 자동으로 첫 번째 수신 가능 컴포넌트로 전달됩니다.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="transfer-mode">데이터 전달 모드</Label>
|
||
|
|
<Select
|
||
|
|
value={config.action?.dataTransfer?.mode || "append"}
|
||
|
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.mode", value)}
|
||
|
|
>
|
||
|
|
<SelectTrigger className="h-8 text-xs">
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="append">추가 (Append)</SelectItem>
|
||
|
|
<SelectItem value="replace">교체 (Replace)</SelectItem>
|
||
|
|
<SelectItem value="merge">병합 (Merge)</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
<p className="text-muted-foreground mt-1 text-xs">기존 데이터를 어떻게 처리할지 선택</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div className="space-y-0.5">
|
||
|
|
<Label htmlFor="clear-after-transfer">전달 후 소스 선택 초기화</Label>
|
||
|
|
<p className="text-muted-foreground text-xs">데이터 전달 후 소스의 선택을 해제합니다</p>
|
||
|
|
</div>
|
||
|
|
<Switch
|
||
|
|
id="clear-after-transfer"
|
||
|
|
checked={config.action?.dataTransfer?.clearAfterTransfer === true}
|
||
|
|
onCheckedChange={(checked) =>
|
||
|
|
onUpdateProperty("componentConfig.action.dataTransfer.clearAfterTransfer", checked)
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div className="space-y-0.5">
|
||
|
|
<Label htmlFor="confirm-before-transfer">전달 전 확인 메시지</Label>
|
||
|
|
<p className="text-muted-foreground text-xs">데이터 전달 전 확인 다이얼로그를 표시합니다</p>
|
||
|
|
</div>
|
||
|
|
<Switch
|
||
|
|
id="confirm-before-transfer"
|
||
|
|
checked={config.action?.dataTransfer?.confirmBeforeTransfer === true}
|
||
|
|
onCheckedChange={(checked) =>
|
||
|
|
onUpdateProperty("componentConfig.action.dataTransfer.confirmBeforeTransfer", checked)
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{config.action?.dataTransfer?.confirmBeforeTransfer && (
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="confirm-message">확인 메시지</Label>
|
||
|
|
<Input
|
||
|
|
id="confirm-message"
|
||
|
|
placeholder="선택한 항목을 전달하시겠습니까?"
|
||
|
|
value={config.action?.dataTransfer?.confirmMessage || ""}
|
||
|
|
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.confirmMessage", e.target.value)}
|
||
|
|
className="h-8 text-xs"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>검증 설정</Label>
|
||
|
|
<div className="space-y-2 rounded-md border p-3">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Label htmlFor="min-selection" className="text-xs">
|
||
|
|
최소 선택 개수
|
||
|
|
</Label>
|
||
|
|
<Input
|
||
|
|
id="min-selection"
|
||
|
|
type="number"
|
||
|
|
placeholder="0"
|
||
|
|
value={config.action?.dataTransfer?.validation?.minSelection || ""}
|
||
|
|
onChange={(e) =>
|
||
|
|
onUpdateProperty(
|
||
|
|
"componentConfig.action.dataTransfer.validation.minSelection",
|
||
|
|
parseInt(e.target.value) || 0,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
className="h-8 w-20 text-xs"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Label htmlFor="max-selection" className="text-xs">
|
||
|
|
최대 선택 개수
|
||
|
|
</Label>
|
||
|
|
<Input
|
||
|
|
id="max-selection"
|
||
|
|
type="number"
|
||
|
|
placeholder="제한없음"
|
||
|
|
value={config.action?.dataTransfer?.validation?.maxSelection || ""}
|
||
|
|
onChange={(e) =>
|
||
|
|
onUpdateProperty(
|
||
|
|
"componentConfig.action.dataTransfer.validation.maxSelection",
|
||
|
|
parseInt(e.target.value) || undefined,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
className="h-8 w-20 text-xs"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>추가 데이터 소스 (선택사항)</Label>
|
||
|
|
<p className="text-muted-foreground text-xs">
|
||
|
|
조건부 컨테이너의 카테고리 값 등 추가 데이터를 함께 전달할 수 있습니다
|
||
|
|
</p>
|
||
|
|
<div className="space-y-2 rounded-md border p-3">
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs">추가 컴포넌트</Label>
|
||
|
|
<Select
|
||
|
|
value={config.action?.dataTransfer?.additionalSources?.[0]?.componentId || ""}
|
||
|
|
onValueChange={(value) => {
|
||
|
|
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||
|
|
const newSources = [...currentSources];
|
||
|
|
if (newSources.length === 0) {
|
||
|
|
newSources.push({ componentId: value, fieldName: "" });
|
||
|
|
} else {
|
||
|
|
newSources[0] = { ...newSources[0], componentId: value };
|
||
|
|
}
|
||
|
|
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<SelectTrigger className="h-8 text-xs">
|
||
|
|
<SelectValue placeholder="추가 데이터 컴포넌트 선택 (선택사항)" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="__clear__">
|
||
|
|
<span className="text-muted-foreground">선택 안 함</span>
|
||
|
|
</SelectItem>
|
||
|
|
{allComponents
|
||
|
|
.filter((comp: any) => {
|
||
|
|
const type = comp.componentType || comp.type || "";
|
||
|
|
return ["conditional-container", "select-basic", "select", "combobox"].some((t) =>
|
||
|
|
type.includes(t),
|
||
|
|
);
|
||
|
|
})
|
||
|
|
.map((comp: any) => {
|
||
|
|
const compType = comp.componentType || comp.type || "unknown";
|
||
|
|
const compLabel = comp.label || comp.componentConfig?.controlLabel || comp.id;
|
||
|
|
return (
|
||
|
|
<SelectItem key={comp.id} value={comp.id}>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<span className="text-xs font-medium">{compLabel}</span>
|
||
|
|
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||
|
|
</div>
|
||
|
|
</SelectItem>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
<p className="text-muted-foreground mt-1 text-xs">
|
||
|
|
조건부 컨테이너, 셀렉트박스 등 (카테고리 값 전달용)
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="additional-field-name" className="text-xs">
|
||
|
|
타겟 필드명 (선택사항)
|
||
|
|
</Label>
|
||
|
|
<Popover>
|
||
|
|
<PopoverTrigger asChild>
|
||
|
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||
|
|
{(() => {
|
||
|
|
const fieldName = config.action?.dataTransfer?.additionalSources?.[0]?.fieldName;
|
||
|
|
if (!fieldName) return "필드 선택 (비워두면 전체 데이터)";
|
||
|
|
const cols = mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns;
|
||
|
|
const found = cols.find((c) => c.name === fieldName);
|
||
|
|
return found ? `${found.label || found.name}` : fieldName;
|
||
|
|
})()}
|
||
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||
|
|
</Button>
|
||
|
|
</PopoverTrigger>
|
||
|
|
<PopoverContent className="w-[240px] p-0" align="start">
|
||
|
|
<Command>
|
||
|
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||
|
|
<CommandList>
|
||
|
|
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||
|
|
<CommandGroup>
|
||
|
|
<CommandItem
|
||
|
|
value="__none__"
|
||
|
|
onSelect={() => {
|
||
|
|
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||
|
|
const newSources = [...currentSources];
|
||
|
|
if (newSources.length === 0) {
|
||
|
|
newSources.push({ componentId: "", fieldName: "" });
|
||
|
|
} else {
|
||
|
|
newSources[0] = { ...newSources[0], fieldName: "" };
|
||
|
|
}
|
||
|
|
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||
|
|
}}
|
||
|
|
className="text-xs"
|
||
|
|
>
|
||
|
|
<Check
|
||
|
|
className={cn(
|
||
|
|
"mr-2 h-3 w-3",
|
||
|
|
!config.action?.dataTransfer?.additionalSources?.[0]?.fieldName ? "opacity-100" : "opacity-0",
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
<span className="text-muted-foreground">선택 안 함 (전체 데이터 병합)</span>
|
||
|
|
</CommandItem>
|
||
|
|
{(mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns).map((col) => (
|
||
|
|
<CommandItem
|
||
|
|
key={col.name}
|
||
|
|
value={`${col.label || ""} ${col.name}`}
|
||
|
|
onSelect={() => {
|
||
|
|
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||
|
|
const newSources = [...currentSources];
|
||
|
|
if (newSources.length === 0) {
|
||
|
|
newSources.push({ componentId: "", fieldName: col.name });
|
||
|
|
} else {
|
||
|
|
newSources[0] = { ...newSources[0], fieldName: col.name };
|
||
|
|
}
|
||
|
|
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||
|
|
}}
|
||
|
|
className="text-xs"
|
||
|
|
>
|
||
|
|
<Check
|
||
|
|
className={cn(
|
||
|
|
"mr-2 h-3 w-3",
|
||
|
|
config.action?.dataTransfer?.additionalSources?.[0]?.fieldName === col.name
|
||
|
|
? "opacity-100"
|
||
|
|
: "opacity-0",
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
<span className="font-medium">{col.label || col.name}</span>
|
||
|
|
{col.label && col.label !== col.name && (
|
||
|
|
<span className="text-muted-foreground ml-1 text-[10px]">({col.name})</span>
|
||
|
|
)}
|
||
|
|
</CommandItem>
|
||
|
|
))}
|
||
|
|
</CommandGroup>
|
||
|
|
</CommandList>
|
||
|
|
</Command>
|
||
|
|
</PopoverContent>
|
||
|
|
</Popover>
|
||
|
|
<p className="text-muted-foreground mt-1 text-xs">추가 데이터가 저장될 타겟 테이블 컬럼</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-3">
|
||
|
|
<Label>필드 매핑 설정</Label>
|
||
|
|
<div className="space-y-1">
|
||
|
|
<Label className="text-xs">타겟 테이블</Label>
|
||
|
|
<Popover>
|
||
|
|
<PopoverTrigger asChild>
|
||
|
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||
|
|
{config.action?.dataTransfer?.targetTable
|
||
|
|
? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
|
||
|
|
config.action?.dataTransfer?.targetTable
|
||
|
|
: "타겟 테이블 선택"}
|
||
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||
|
|
</Button>
|
||
|
|
</PopoverTrigger>
|
||
|
|
<PopoverContent className="w-[250px] p-0" align="start">
|
||
|
|
<Command>
|
||
|
|
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||
|
|
<CommandList>
|
||
|
|
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||
|
|
<CommandGroup>
|
||
|
|
{availableTables.map((table) => (
|
||
|
|
<CommandItem
|
||
|
|
key={table.name}
|
||
|
|
value={`${table.label} ${table.name}`}
|
||
|
|
onSelect={() => {
|
||
|
|
onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name);
|
||
|
|
}}
|
||
|
|
className="text-xs"
|
||
|
|
>
|
||
|
|
<Check
|
||
|
|
className={cn(
|
||
|
|
"mr-2 h-3 w-3",
|
||
|
|
config.action?.dataTransfer?.targetTable === table.name ? "opacity-100" : "opacity-0",
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
<span className="font-medium">{table.label}</span>
|
||
|
|
<span className="text-muted-foreground ml-1">({table.name})</span>
|
||
|
|
</CommandItem>
|
||
|
|
))}
|
||
|
|
</CommandGroup>
|
||
|
|
</CommandList>
|
||
|
|
</Command>
|
||
|
|
</PopoverContent>
|
||
|
|
</Popover>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<Label className="text-xs">소스 테이블별 매핑</Label>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
className="h-6 text-[10px]"
|
||
|
|
onClick={() => {
|
||
|
|
const currentMappings = config.action?.dataTransfer?.multiTableMappings || [];
|
||
|
|
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", [
|
||
|
|
...currentMappings,
|
||
|
|
{ sourceTable: "", mappingRules: [] },
|
||
|
|
]);
|
||
|
|
setActiveMappingGroupIndex(currentMappings.length);
|
||
|
|
}}
|
||
|
|
disabled={!config.action?.dataTransfer?.targetTable}
|
||
|
|
>
|
||
|
|
<Plus className="mr-1 h-3 w-3" />
|
||
|
|
소스 테이블 추가
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
<p className="text-muted-foreground text-[10px]">
|
||
|
|
여러 소스 테이블에서 데이터를 전달할 때, 각 테이블별로 매핑 규칙을 설정합니다. 런타임에 소스 테이블을 자동 감지합니다.
|
||
|
|
</p>
|
||
|
|
|
||
|
|
{!config.action?.dataTransfer?.targetTable ? (
|
||
|
|
<div className="rounded-md border border-dashed p-3 text-center">
|
||
|
|
<p className="text-muted-foreground text-xs">먼저 타겟 테이블을 선택하세요.</p>
|
||
|
|
</div>
|
||
|
|
) : !(config.action?.dataTransfer?.multiTableMappings || []).length ? (
|
||
|
|
<div className="rounded-md border border-dashed p-3 text-center">
|
||
|
|
<p className="text-muted-foreground text-xs">매핑 그룹이 없습니다. 소스 테이블을 추가하세요.</p>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-2">
|
||
|
|
<div className="flex flex-wrap gap-1">
|
||
|
|
{(config.action?.dataTransfer?.multiTableMappings || []).map((group: any, gIdx: number) => (
|
||
|
|
<div key={gIdx} className="flex items-center gap-0.5">
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant={activeMappingGroupIndex === gIdx ? "default" : "outline"}
|
||
|
|
size="sm"
|
||
|
|
className="h-6 text-[10px]"
|
||
|
|
onClick={() => setActiveMappingGroupIndex(gIdx)}
|
||
|
|
>
|
||
|
|
{group.sourceTable
|
||
|
|
? availableTables.find((t) => t.name === group.sourceTable)?.label || group.sourceTable
|
||
|
|
: `그룹 ${gIdx + 1}`}
|
||
|
|
{group.mappingRules?.length > 0 && (
|
||
|
|
<span className="bg-primary/20 ml-1 rounded-full px-1 text-[9px]">
|
||
|
|
{group.mappingRules.length}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="text-destructive hover:bg-destructive/10 h-5 w-5"
|
||
|
|
onClick={() => {
|
||
|
|
const mappings = [...(config.action?.dataTransfer?.multiTableMappings || [])];
|
||
|
|
mappings.splice(gIdx, 1);
|
||
|
|
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
|
||
|
|
if (activeMappingGroupIndex >= mappings.length) {
|
||
|
|
setActiveMappingGroupIndex(Math.max(0, mappings.length - 1));
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<X className="h-3 w-3" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{(() => {
|
||
|
|
const multiMappings = config.action?.dataTransfer?.multiTableMappings || [];
|
||
|
|
const activeGroup = multiMappings[activeMappingGroupIndex];
|
||
|
|
if (!activeGroup) return null;
|
||
|
|
|
||
|
|
const activeSourceTable = activeGroup.sourceTable || "";
|
||
|
|
const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || [];
|
||
|
|
const activeRules: any[] = activeGroup.mappingRules || [];
|
||
|
|
|
||
|
|
const updateGroupField = (field: string, value: any) => {
|
||
|
|
const mappings = [...multiMappings];
|
||
|
|
mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value };
|
||
|
|
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-2 rounded-md border p-3">
|
||
|
|
<div className="space-y-1">
|
||
|
|
<Label className="text-xs">소스 테이블</Label>
|
||
|
|
<Popover>
|
||
|
|
<PopoverTrigger asChild>
|
||
|
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||
|
|
{activeSourceTable
|
||
|
|
? availableTables.find((t) => t.name === activeSourceTable)?.label || activeSourceTable
|
||
|
|
: "소스 테이블 선택"}
|
||
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||
|
|
</Button>
|
||
|
|
</PopoverTrigger>
|
||
|
|
<PopoverContent className="w-[250px] p-0" align="start">
|
||
|
|
<Command>
|
||
|
|
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||
|
|
<CommandList>
|
||
|
|
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||
|
|
<CommandGroup>
|
||
|
|
{availableTables.map((table) => (
|
||
|
|
<CommandItem
|
||
|
|
key={table.name}
|
||
|
|
value={`${table.label} ${table.name}`}
|
||
|
|
onSelect={async () => {
|
||
|
|
updateGroupField("sourceTable", table.name);
|
||
|
|
if (!mappingSourceColumnsMap[table.name]) {
|
||
|
|
const cols = await loadMappingColumns(table.name);
|
||
|
|
setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols }));
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
className="text-xs"
|
||
|
|
>
|
||
|
|
<Check
|
||
|
|
className={cn(
|
||
|
|
"mr-2 h-3 w-3",
|
||
|
|
activeSourceTable === table.name ? "opacity-100" : "opacity-0",
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
<span className="font-medium">{table.label}</span>
|
||
|
|
<span className="text-muted-foreground ml-1">({table.name})</span>
|
||
|
|
</CommandItem>
|
||
|
|
))}
|
||
|
|
</CommandGroup>
|
||
|
|
</CommandList>
|
||
|
|
</Command>
|
||
|
|
</PopoverContent>
|
||
|
|
</Popover>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-1">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<Label className="text-[10px]">매핑 규칙</Label>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
className="h-5 text-[10px]"
|
||
|
|
onClick={() => {
|
||
|
|
updateGroupField("mappingRules", [...activeRules, { sourceField: "", targetField: "" }]);
|
||
|
|
}}
|
||
|
|
disabled={!activeSourceTable}
|
||
|
|
>
|
||
|
|
<Plus className="mr-1 h-3 w-3" />
|
||
|
|
추가
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{!activeSourceTable ? (
|
||
|
|
<p className="text-muted-foreground text-[10px]">소스 테이블을 먼저 선택하세요.</p>
|
||
|
|
) : activeRules.length === 0 ? (
|
||
|
|
<p className="text-muted-foreground text-[10px]">매핑 없음 (동일 필드명 자동 매핑)</p>
|
||
|
|
) : (
|
||
|
|
activeRules.map((rule: any, rIdx: number) => {
|
||
|
|
const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`;
|
||
|
|
const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`;
|
||
|
|
return (
|
||
|
|
<div key={rIdx} className="bg-background flex items-center gap-2 rounded-md border p-2">
|
||
|
|
<div className="flex-1">
|
||
|
|
<Popover
|
||
|
|
open={mappingSourcePopoverOpen[popoverKeyS] || false}
|
||
|
|
onOpenChange={(open) =>
|
||
|
|
setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: open }))
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<PopoverTrigger asChild>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
role="combobox"
|
||
|
|
className="h-7 w-full justify-between text-xs"
|
||
|
|
>
|
||
|
|
{rule.sourceField
|
||
|
|
? activeSourceColumns.find((c) => c.name === rule.sourceField)?.label ||
|
||
|
|
rule.sourceField
|
||
|
|
: "소스 필드"}
|
||
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||
|
|
</Button>
|
||
|
|
</PopoverTrigger>
|
||
|
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||
|
|
<Command>
|
||
|
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||
|
|
<CommandList>
|
||
|
|
<CommandEmpty className="py-2 text-center text-xs">컬럼 없음</CommandEmpty>
|
||
|
|
<CommandGroup>
|
||
|
|
{activeSourceColumns.map((col) => (
|
||
|
|
<CommandItem
|
||
|
|
key={col.name}
|
||
|
|
value={`${col.label} ${col.name}`}
|
||
|
|
onSelect={() => {
|
||
|
|
const newRules = [...activeRules];
|
||
|
|
newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name };
|
||
|
|
updateGroupField("mappingRules", newRules);
|
||
|
|
setMappingSourcePopoverOpen((prev) => ({
|
||
|
|
...prev,
|
||
|
|
[popoverKeyS]: false,
|
||
|
|
}));
|
||
|
|
}}
|
||
|
|
className="text-xs"
|
||
|
|
>
|
||
|
|
<Check
|
||
|
|
className={cn(
|
||
|
|
"mr-2 h-3 w-3",
|
||
|
|
rule.sourceField === col.name ? "opacity-100" : "opacity-0",
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
<span>{col.label}</span>
|
||
|
|
{col.label !== col.name && (
|
||
|
|
<span className="text-muted-foreground ml-1">({col.name})</span>
|
||
|
|
)}
|
||
|
|
</CommandItem>
|
||
|
|
))}
|
||
|
|
</CommandGroup>
|
||
|
|
</CommandList>
|
||
|
|
</Command>
|
||
|
|
</PopoverContent>
|
||
|
|
</Popover>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<span className="text-muted-foreground text-xs">→</span>
|
||
|
|
|
||
|
|
<div className="flex-1">
|
||
|
|
<Popover
|
||
|
|
open={mappingTargetPopoverOpen[popoverKeyT] || false}
|
||
|
|
onOpenChange={(open) =>
|
||
|
|
setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: open }))
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<PopoverTrigger asChild>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
role="combobox"
|
||
|
|
className="h-7 w-full justify-between text-xs"
|
||
|
|
>
|
||
|
|
{rule.targetField
|
||
|
|
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label ||
|
||
|
|
rule.targetField
|
||
|
|
: "타겟 필드"}
|
||
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||
|
|
</Button>
|
||
|
|
</PopoverTrigger>
|
||
|
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||
|
|
<Command>
|
||
|
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||
|
|
<CommandList>
|
||
|
|
<CommandEmpty className="py-2 text-center text-xs">컬럼 없음</CommandEmpty>
|
||
|
|
<CommandGroup>
|
||
|
|
{mappingTargetColumns.map((col) => (
|
||
|
|
<CommandItem
|
||
|
|
key={col.name}
|
||
|
|
value={`${col.label} ${col.name}`}
|
||
|
|
onSelect={() => {
|
||
|
|
const newRules = [...activeRules];
|
||
|
|
newRules[rIdx] = { ...newRules[rIdx], targetField: col.name };
|
||
|
|
updateGroupField("mappingRules", newRules);
|
||
|
|
setMappingTargetPopoverOpen((prev) => ({
|
||
|
|
...prev,
|
||
|
|
[popoverKeyT]: false,
|
||
|
|
}));
|
||
|
|
}}
|
||
|
|
className="text-xs"
|
||
|
|
>
|
||
|
|
<Check
|
||
|
|
className={cn(
|
||
|
|
"mr-2 h-3 w-3",
|
||
|
|
rule.targetField === col.name ? "opacity-100" : "opacity-0",
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
<span>{col.label}</span>
|
||
|
|
{col.label !== col.name && (
|
||
|
|
<span className="text-muted-foreground ml-1">({col.name})</span>
|
||
|
|
)}
|
||
|
|
</CommandItem>
|
||
|
|
))}
|
||
|
|
</CommandGroup>
|
||
|
|
</CommandList>
|
||
|
|
</Command>
|
||
|
|
</PopoverContent>
|
||
|
|
</Popover>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="text-destructive hover:bg-destructive/10 h-7 w-7"
|
||
|
|
onClick={() => {
|
||
|
|
const newRules = [...activeRules];
|
||
|
|
newRules.splice(rIdx, 1);
|
||
|
|
updateGroupField("mappingRules", newRules);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<X className="h-3 w-3" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})()}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||
|
|
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||
|
|
<strong>사용 방법:</strong>
|
||
|
|
<br />
|
||
|
|
1. 소스 컴포넌트에서 데이터를 선택합니다
|
||
|
|
<br />
|
||
|
|
2. 소스 테이블별로 필드 매핑 규칙을 설정합니다
|
||
|
|
<br />
|
||
|
|
3. 이 버튼을 클릭하면 소스 테이블을 자동 감지하여 매핑된 데이터가 타겟으로 전달됩니다
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|