- Enhanced the `ScreenManagementService` to include updates for V2 layouts in the `screen_layouts_v2` table. - Implemented logic to remap `screenId`, `targetScreenId`, `modalScreenId`, and other related IDs in layout data. - Added logging for the number of layouts updated in both V1 and V2, improving traceability of the update process. - This update ensures that screen references are correctly maintained across different layout versions, enhancing the overall functionality of the screen management system.
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>
|
|
);
|
|
};
|