- Implemented logic to load reference table columns based on selected target table, improving the configurability of button mappings. - Added error handling for API calls to ensure robustness when fetching column data. - Updated dependencies in the useEffect hook to ensure proper reactivity when available tables change. These enhancements aim to provide users with a more flexible and dynamic configuration experience in the V2ButtonConfigPanel, allowing for better management of button mappings based on table relationships.
2074 lines
81 KiB
TypeScript
2074 lines
81 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* V2Button 설정 패널
|
|
* 토스식 단계별 UX: 액션 유형 카드 선택 -> 표시 모드 카드 -> 액션별 세부 설정 -> 고급 설정(접힘)
|
|
*/
|
|
|
|
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from "@/components/ui/collapsible";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import {
|
|
Save,
|
|
Trash2,
|
|
Pencil,
|
|
ArrowRight,
|
|
Maximize2,
|
|
SendHorizontal,
|
|
Download,
|
|
Upload,
|
|
Zap,
|
|
Settings,
|
|
ChevronDown,
|
|
Check,
|
|
Plus,
|
|
X,
|
|
Type,
|
|
Image,
|
|
Columns,
|
|
ScanLine,
|
|
Truck,
|
|
Send,
|
|
Copy,
|
|
FileSpreadsheet,
|
|
ChevronsUpDown,
|
|
Info,
|
|
Workflow,
|
|
} from "lucide-react";
|
|
import { icons as allLucideIcons } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import {
|
|
actionIconMap,
|
|
noIconActions,
|
|
NO_ICON_MESSAGE,
|
|
iconSizePresets,
|
|
getLucideIcon,
|
|
addToIconMap,
|
|
getDefaultIconForAction,
|
|
sanitizeSvg,
|
|
} from "@/lib/button-icon-map";
|
|
import { ImprovedButtonControlConfigPanel } from "@/components/screen/config-panels/ImprovedButtonControlConfigPanel";
|
|
import { FlowVisibilityConfigPanel } from "@/components/screen/config-panels/FlowVisibilityConfigPanel";
|
|
import type { ComponentData } from "@/types/screen";
|
|
|
|
// ─── 액션 유형 카드 정의 ───
|
|
const ACTION_TYPE_CARDS = [
|
|
{
|
|
value: "save",
|
|
icon: Save,
|
|
title: "저장",
|
|
description: "데이터를 저장해요",
|
|
},
|
|
{
|
|
value: "delete",
|
|
icon: Trash2,
|
|
title: "삭제",
|
|
description: "데이터를 삭제해요",
|
|
},
|
|
{
|
|
value: "edit",
|
|
icon: Pencil,
|
|
title: "편집",
|
|
description: "데이터를 수정해요",
|
|
},
|
|
{
|
|
value: "modal",
|
|
icon: Maximize2,
|
|
title: "모달 열기",
|
|
description: "팝업 화면을 열어요",
|
|
},
|
|
{
|
|
value: "navigate",
|
|
icon: ArrowRight,
|
|
title: "페이지 이동",
|
|
description: "다른 화면으로 이동해요",
|
|
},
|
|
{
|
|
value: "transferData",
|
|
icon: SendHorizontal,
|
|
title: "데이터 전달",
|
|
description: "다른 테이블로 전달해요",
|
|
},
|
|
{
|
|
value: "excel_download",
|
|
icon: Download,
|
|
title: "엑셀 다운로드",
|
|
description: "데이터를 엑셀로 받아요",
|
|
},
|
|
{
|
|
value: "excel_upload",
|
|
icon: Upload,
|
|
title: "엑셀 업로드",
|
|
description: "엑셀 파일을 올려요",
|
|
},
|
|
{
|
|
value: "approval",
|
|
icon: Check,
|
|
title: "결재 요청",
|
|
description: "결재를 요청해요",
|
|
},
|
|
{
|
|
value: "control",
|
|
icon: Settings,
|
|
title: "제어 흐름",
|
|
description: "흐름을 제어해요",
|
|
},
|
|
{
|
|
value: "copy",
|
|
icon: Copy,
|
|
title: "복사",
|
|
description: "데이터를 복사해요",
|
|
},
|
|
{
|
|
value: "barcode_scan",
|
|
icon: ScanLine,
|
|
title: "바코드 스캔",
|
|
description: "바코드를 스캔해요",
|
|
},
|
|
{
|
|
value: "operation_control",
|
|
icon: Truck,
|
|
title: "운행알림/종료",
|
|
description: "운행을 관리해요",
|
|
},
|
|
{
|
|
value: "multi_table_excel_upload",
|
|
icon: FileSpreadsheet,
|
|
title: "다중 엑셀 업로드",
|
|
description: "여러 테이블에 올려요",
|
|
},
|
|
] as const;
|
|
|
|
// ─── 표시 모드 카드 정의 ───
|
|
const DISPLAY_MODE_CARDS = [
|
|
{
|
|
value: "text" as const,
|
|
icon: Type,
|
|
title: "텍스트",
|
|
description: "텍스트만 표시",
|
|
},
|
|
{
|
|
value: "icon" as const,
|
|
icon: Image,
|
|
title: "아이콘",
|
|
description: "아이콘만 표시",
|
|
},
|
|
{
|
|
value: "icon-text" as const,
|
|
icon: Columns,
|
|
title: "아이콘+텍스트",
|
|
description: "둘 다 표시",
|
|
},
|
|
] as const;
|
|
|
|
// ─── 버튼 변형 옵션 ───
|
|
const VARIANT_OPTIONS = [
|
|
{ value: "primary", label: "기본 (Primary)" },
|
|
{ value: "secondary", label: "보조 (Secondary)" },
|
|
{ value: "danger", label: "위험 (Danger)" },
|
|
] as const;
|
|
|
|
interface ScreenOption {
|
|
id: number;
|
|
name: string;
|
|
description?: string;
|
|
}
|
|
|
|
interface V2ButtonConfigPanelProps {
|
|
config: Record<string, any>;
|
|
onChange: (config: Record<string, any>) => void;
|
|
component?: ComponentData;
|
|
currentComponent?: ComponentData;
|
|
onUpdateProperty?: (path: string, value: any) => void;
|
|
allComponents?: ComponentData[];
|
|
currentTableName?: string;
|
|
screenTableName?: string;
|
|
currentScreenCompanyCode?: string;
|
|
[key: string]: any;
|
|
}
|
|
|
|
export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
|
|
config,
|
|
onChange,
|
|
component,
|
|
currentComponent,
|
|
onUpdateProperty,
|
|
allComponents = [],
|
|
currentTableName,
|
|
screenTableName,
|
|
currentScreenCompanyCode,
|
|
}) => {
|
|
const effectiveComponent = component || currentComponent;
|
|
const effectiveTableName = currentTableName || screenTableName;
|
|
const actionType = String(config.action?.type || "save");
|
|
const displayMode = (config.displayMode as "text" | "icon" | "icon-text") || "text";
|
|
const variant = config.variant || "primary";
|
|
const buttonText = config.text !== undefined ? config.text : "버튼";
|
|
|
|
// 아이콘 상태
|
|
const [selectedIcon, setSelectedIcon] = useState<string>(config.icon?.name || "");
|
|
const [selectedIconType, setSelectedIconType] = useState<"lucide" | "svg">(
|
|
config.icon?.type || "lucide"
|
|
);
|
|
const [iconSize, setIconSize] = useState<string>(config.icon?.size || "보통");
|
|
|
|
// UI 상태
|
|
const [iconSectionOpen, setIconSectionOpen] = useState(false);
|
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
|
const [lucideSearchOpen, setLucideSearchOpen] = useState(false);
|
|
const [lucideSearchTerm, setLucideSearchTerm] = useState("");
|
|
const [svgPasteOpen, setSvgPasteOpen] = useState(false);
|
|
const [svgInput, setSvgInput] = useState("");
|
|
const [svgName, setSvgName] = useState("");
|
|
const [svgError, setSvgError] = useState("");
|
|
|
|
// 모달 관련
|
|
const [screens, setScreens] = useState<ScreenOption[]>([]);
|
|
const [screensLoading, setScreensLoading] = useState(false);
|
|
const [modalScreenOpen, setModalScreenOpen] = useState(false);
|
|
const [modalSearchTerm, setModalSearchTerm] = useState("");
|
|
|
|
// 데이터 전달 필드 매핑 관련
|
|
const [availableTables, setAvailableTables] = useState<Array<{ name: string; label: string }>>([]);
|
|
const [mappingSourceColumnsMap, setMappingSourceColumnsMap] = useState<Record<string, Array<{ name: string; label: string }>>>({});
|
|
const [mappingTargetColumns, setMappingTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
|
|
const [fieldMappingOpen, setFieldMappingOpen] = useState(false);
|
|
const [activeMappingGroupIndex, setActiveMappingGroupIndex] = useState(0);
|
|
|
|
const showIconSettings = displayMode === "icon" || displayMode === "icon-text";
|
|
const currentActionIcons = actionIconMap[actionType] || [];
|
|
const isNoIconAction = noIconActions.has(actionType);
|
|
const customIcons: string[] = config.customIcons || [];
|
|
const customSvgIcons: Array<{ name: string; svg: string }> = config.customSvgIcons || [];
|
|
|
|
// 플로우 위젯 존재 여부
|
|
const hasFlowWidget = useMemo(() => {
|
|
return allComponents.some((comp: any) => {
|
|
const compType = comp.componentType || comp.widgetType || "";
|
|
return compType === "flow-widget" || compType?.toLowerCase().includes("flow");
|
|
});
|
|
}, [allComponents]);
|
|
|
|
// config 업데이트 헬퍼
|
|
const updateConfig = useCallback(
|
|
(field: string, value: any) => {
|
|
onChange({ ...config, [field]: value });
|
|
},
|
|
[config, onChange]
|
|
);
|
|
|
|
const updateActionConfig = useCallback(
|
|
(field: string, value: any) => {
|
|
const currentAction = config.action || {};
|
|
onChange({
|
|
...config,
|
|
action: { ...currentAction, [field]: value },
|
|
});
|
|
},
|
|
[config, onChange]
|
|
);
|
|
|
|
// 기존 서브패널(ImprovedButtonControlConfigPanel 등)이 webTypeConfig.* 경로로 쓰므로
|
|
// 항상 config 기반 onChange로 통일 (onUpdateProperty는 V2 경로 불일치 문제 있음)
|
|
const handleUpdateProperty = useCallback(
|
|
(path: string, value: any) => {
|
|
const normalizedPath = path
|
|
.replace(/^componentConfig\./, "")
|
|
.replace(/^webTypeConfig\./, "");
|
|
const parts = normalizedPath.split(".");
|
|
const newConfig = { ...config };
|
|
let current: any = newConfig;
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
if (!current[parts[i]]) current[parts[i]] = {};
|
|
current[parts[i]] = { ...current[parts[i]] };
|
|
current = current[parts[i]];
|
|
}
|
|
current[parts[parts.length - 1]] = value;
|
|
onChange(newConfig);
|
|
},
|
|
[config, onChange]
|
|
);
|
|
|
|
// prop 변경 시 아이콘 상태 동기화
|
|
useEffect(() => {
|
|
setSelectedIcon(config.icon?.name || "");
|
|
setSelectedIconType(config.icon?.type || "lucide");
|
|
setIconSize(config.icon?.size || "보통");
|
|
}, [config.icon?.name, config.icon?.type, config.icon?.size]);
|
|
|
|
// 테이블 목록 로드 (데이터 전달 액션용)
|
|
useEffect(() => {
|
|
if (actionType !== "transferData") return;
|
|
if (availableTables.length > 0) return;
|
|
|
|
const loadTables = async () => {
|
|
try {
|
|
const response = await apiClient.get("/table-management/tables");
|
|
if (response.data.success && response.data.data) {
|
|
const tables = response.data.data.map((t: any) => ({
|
|
name: t.tableName || t.name,
|
|
label: t.displayName || t.tableLabel || t.label || t.tableName || t.name,
|
|
}));
|
|
setAvailableTables(tables);
|
|
}
|
|
} catch {
|
|
setAvailableTables([]);
|
|
}
|
|
};
|
|
loadTables();
|
|
}, [actionType, availableTables.length]);
|
|
|
|
// 테이블 컬럼 로드 헬퍼
|
|
const loadTableColumns = useCallback(async (tableName: string): Promise<Array<{ name: string; label: string }>> => {
|
|
try {
|
|
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
|
if (response.data.success) {
|
|
let columnData = response.data.data;
|
|
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
|
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
|
if (Array.isArray(columnData)) {
|
|
return columnData.map((col: any) => ({
|
|
name: col.name || col.columnName,
|
|
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
|
}));
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
return [];
|
|
}, []);
|
|
|
|
// 멀티 테이블 매핑: 소스/타겟 테이블 컬럼 로드
|
|
useEffect(() => {
|
|
if (actionType !== "transferData") return;
|
|
|
|
const multiTableMappings: Array<{ sourceTable: string }> = config.action?.dataTransfer?.multiTableMappings || [];
|
|
const targetTable = config.action?.dataTransfer?.targetTable;
|
|
|
|
const loadAll = async () => {
|
|
const sourceTableNames = multiTableMappings.map((m) => m.sourceTable).filter(Boolean);
|
|
const newMap: Record<string, Array<{ name: string; label: string }>> = {};
|
|
for (const tbl of sourceTableNames) {
|
|
if (!mappingSourceColumnsMap[tbl]) {
|
|
newMap[tbl] = await loadTableColumns(tbl);
|
|
}
|
|
}
|
|
if (Object.keys(newMap).length > 0) {
|
|
setMappingSourceColumnsMap((prev) => ({ ...prev, ...newMap }));
|
|
}
|
|
|
|
if (targetTable) {
|
|
const cols = await loadTableColumns(targetTable);
|
|
|
|
try {
|
|
const fullResponse = await apiClient.get(`/table-management/tables/${targetTable}/columns`);
|
|
let fullColumnData = fullResponse.data?.data;
|
|
if (!Array.isArray(fullColumnData) && fullColumnData?.columns) fullColumnData = fullColumnData.columns;
|
|
if (!Array.isArray(fullColumnData) && fullColumnData?.data) fullColumnData = fullColumnData.data;
|
|
|
|
if (Array.isArray(fullColumnData)) {
|
|
const refTableSet = new Set<string>();
|
|
fullColumnData.forEach((col: any) => {
|
|
const inputType = col.inputType || col.input_type;
|
|
if (inputType !== "entity") return;
|
|
let refTable = col.referenceTable || col.reference_table;
|
|
if (!refTable && col.detailSettings) {
|
|
try {
|
|
const ds = typeof col.detailSettings === "string" ? JSON.parse(col.detailSettings) : col.detailSettings;
|
|
refTable = ds?.referenceTable;
|
|
} catch { /* ignore */ }
|
|
}
|
|
if (refTable) refTableSet.add(refTable);
|
|
});
|
|
|
|
const targetColumnNames = new Set(cols.map((c) => c.name));
|
|
for (const refTable of refTableSet) {
|
|
const refCols = await loadTableColumns(refTable);
|
|
const refTableLabel = availableTables.find((t) => t.name === refTable)?.label || refTable;
|
|
refCols.forEach((rc) => {
|
|
if (!targetColumnNames.has(rc.name)) {
|
|
cols.push({
|
|
name: rc.name,
|
|
label: `${rc.label} [${refTableLabel}]`,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
setMappingTargetColumns(cols);
|
|
} else {
|
|
setMappingTargetColumns([]);
|
|
}
|
|
};
|
|
loadAll();
|
|
}, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, availableTables, loadTableColumns]);
|
|
|
|
// 화면 목록 로드 (모달 액션용)
|
|
useEffect(() => {
|
|
if (actionType !== "modal" && actionType !== "navigate") return;
|
|
if (screens.length > 0) return;
|
|
|
|
const loadScreens = async () => {
|
|
setScreensLoading(true);
|
|
try {
|
|
const response = await apiClient.get("/screen-management/screens?size=1000");
|
|
if (response.data.success && response.data.data) {
|
|
const screenList = response.data.data.map((s: any) => ({
|
|
id: s.id || s.screenId,
|
|
name: s.name || s.screenName,
|
|
description: s.description || "",
|
|
}));
|
|
setScreens(screenList);
|
|
}
|
|
} catch {
|
|
setScreens([]);
|
|
} finally {
|
|
setScreensLoading(false);
|
|
}
|
|
};
|
|
loadScreens();
|
|
}, [actionType, screens.length]);
|
|
|
|
// 아이콘 선택 핸들러
|
|
const handleSelectIcon = (iconName: string, iconType: "lucide" | "svg" = "lucide") => {
|
|
setSelectedIcon(iconName);
|
|
setSelectedIconType(iconType);
|
|
updateConfig("icon", {
|
|
name: iconName,
|
|
type: iconType,
|
|
size: iconSize,
|
|
});
|
|
};
|
|
|
|
const revertToDefaultIcon = () => {
|
|
const def = getDefaultIconForAction(actionType);
|
|
handleSelectIcon(def.name, def.type);
|
|
};
|
|
|
|
// 액션 유형 변경 핸들러
|
|
const handleActionTypeChange = (newType: string) => {
|
|
const currentAction = config.action || {};
|
|
onChange({
|
|
...config,
|
|
action: { ...currentAction, type: newType },
|
|
});
|
|
|
|
// 아이콘이 새 액션 추천에 없으면 초기화
|
|
const newActionIcons = actionIconMap[newType] || [];
|
|
if (selectedIcon && selectedIconType === "lucide" && !newActionIcons.includes(selectedIcon) && !customIcons.includes(selectedIcon)) {
|
|
setSelectedIcon("");
|
|
updateConfig("icon", undefined);
|
|
}
|
|
};
|
|
|
|
// componentData 생성 (기존 패널 재사용용)
|
|
// effectiveComponent가 있어도 config 변경분을 반드시 반영해야 토글 등이 동작함
|
|
const componentData = useMemo(() => {
|
|
if (effectiveComponent) {
|
|
return {
|
|
...effectiveComponent,
|
|
componentConfig: config,
|
|
webTypeConfig: config,
|
|
} as ComponentData;
|
|
}
|
|
return {
|
|
id: "virtual",
|
|
type: "widget" as const,
|
|
position: { x: 0, y: 0 },
|
|
size: { width: 120, height: 40 },
|
|
componentConfig: config,
|
|
webTypeConfig: config,
|
|
componentType: "v2-button-primary",
|
|
} as ComponentData;
|
|
}, [effectiveComponent, config]);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* ─── 1단계: 버튼 액션 유형 선택 (가장 중요) ─── */}
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium">이 버튼은 어떤 동작을 하나요?</p>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{ACTION_TYPE_CARDS.map((card) => {
|
|
const Icon = card.icon;
|
|
const isSelected = actionType === card.value;
|
|
return (
|
|
<button
|
|
key={card.value}
|
|
type="button"
|
|
onClick={() => handleActionTypeChange(card.value)}
|
|
className={cn(
|
|
"flex flex-col items-center justify-center rounded-lg border p-2 text-center transition-all min-h-[68px]",
|
|
isSelected
|
|
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
|
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
|
)}
|
|
>
|
|
<Icon className={cn("h-4 w-4 mb-1", isSelected ? "text-primary" : "text-muted-foreground")} />
|
|
<span className="text-[11px] font-medium leading-tight">{card.title}</span>
|
|
<span className="text-[9px] text-muted-foreground leading-tight mt-0.5">{card.description}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* ─── 2단계: 표시 모드 선택 ─── */}
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium">버튼을 어떻게 표시할까요?</p>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{DISPLAY_MODE_CARDS.map((card) => {
|
|
const Icon = card.icon;
|
|
const isSelected = displayMode === card.value;
|
|
return (
|
|
<button
|
|
key={card.value}
|
|
type="button"
|
|
onClick={() => {
|
|
updateConfig("displayMode", card.value);
|
|
if ((card.value === "icon" || card.value === "icon-text") && !selectedIcon) {
|
|
revertToDefaultIcon();
|
|
}
|
|
}}
|
|
className={cn(
|
|
"flex flex-col items-center justify-center rounded-lg border p-2.5 text-center transition-all",
|
|
isSelected
|
|
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
|
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
|
)}
|
|
>
|
|
<Icon className={cn("h-4 w-4 mb-1", isSelected ? "text-primary" : "text-muted-foreground")} />
|
|
<span className="text-xs font-medium">{card.title}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ─── 버튼 텍스트 ─── */}
|
|
{(displayMode === "text" || displayMode === "icon-text") && (
|
|
<div>
|
|
<Label htmlFor="btn-text" className="mb-1.5 text-xs">
|
|
버튼 텍스트
|
|
</Label>
|
|
<Input
|
|
id="btn-text"
|
|
value={buttonText}
|
|
onChange={(e) => updateConfig("text", e.target.value)}
|
|
placeholder="버튼에 표시할 텍스트"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* ─── 버튼 변형 ─── */}
|
|
<div>
|
|
<Label className="mb-1.5 text-xs">버튼 스타일</Label>
|
|
<Select value={variant} onValueChange={(v) => updateConfig("variant", v)}>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{VARIANT_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* ─── 3단계: 액션별 세부 설정 ─── */}
|
|
<ActionDetailSection
|
|
actionType={actionType}
|
|
config={config}
|
|
updateConfig={updateConfig}
|
|
updateActionConfig={updateActionConfig}
|
|
screens={screens}
|
|
screensLoading={screensLoading}
|
|
modalScreenOpen={modalScreenOpen}
|
|
setModalScreenOpen={setModalScreenOpen}
|
|
modalSearchTerm={modalSearchTerm}
|
|
setModalSearchTerm={setModalSearchTerm}
|
|
currentTableName={effectiveTableName}
|
|
allComponents={allComponents}
|
|
handleUpdateProperty={handleUpdateProperty}
|
|
/>
|
|
|
|
{/* ─── 아이콘 설정 (접기) ─── */}
|
|
{showIconSettings && (
|
|
<Collapsible open={iconSectionOpen} onOpenChange={setIconSectionOpen}>
|
|
<CollapsibleTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Image className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium">아이콘 설정</span>
|
|
</div>
|
|
<ChevronDown
|
|
className={cn(
|
|
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
|
iconSectionOpen && "rotate-180"
|
|
)}
|
|
/>
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
|
|
<IconSettingsSection
|
|
actionType={actionType}
|
|
isNoIconAction={isNoIconAction}
|
|
currentActionIcons={currentActionIcons}
|
|
selectedIcon={selectedIcon}
|
|
selectedIconType={selectedIconType}
|
|
iconSize={iconSize}
|
|
displayMode={displayMode}
|
|
iconTextPosition={config.iconTextPosition || "right"}
|
|
iconGap={config.iconGap ?? 6}
|
|
customIcons={customIcons}
|
|
customSvgIcons={customSvgIcons}
|
|
lucideSearchOpen={lucideSearchOpen}
|
|
setLucideSearchOpen={setLucideSearchOpen}
|
|
lucideSearchTerm={lucideSearchTerm}
|
|
setLucideSearchTerm={setLucideSearchTerm}
|
|
svgPasteOpen={svgPasteOpen}
|
|
setSvgPasteOpen={setSvgPasteOpen}
|
|
svgInput={svgInput}
|
|
setSvgInput={setSvgInput}
|
|
svgName={svgName}
|
|
setSvgName={setSvgName}
|
|
svgError={svgError}
|
|
setSvgError={setSvgError}
|
|
onSelectIcon={handleSelectIcon}
|
|
onRevertToDefault={revertToDefaultIcon}
|
|
onIconSizeChange={(preset) => {
|
|
setIconSize(preset);
|
|
if (selectedIcon) {
|
|
updateConfig("icon", { ...config.icon, size: preset });
|
|
}
|
|
}}
|
|
onIconTextPositionChange={(pos) => updateConfig("iconTextPosition", pos)}
|
|
onIconGapChange={(gap) => updateConfig("iconGap", gap)}
|
|
onCustomIconsChange={(icons) => updateConfig("customIcons", icons)}
|
|
onCustomSvgIconsChange={(icons) => updateConfig("customSvgIcons", icons)}
|
|
/>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
)}
|
|
|
|
{/* ─── 고급 설정 (접기) ─── */}
|
|
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
|
<CollapsibleTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium">고급 설정</span>
|
|
</div>
|
|
<ChevronDown
|
|
className={cn(
|
|
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
|
advancedOpen && "rotate-180"
|
|
)}
|
|
/>
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="rounded-b-lg border border-t-0 p-4 space-y-4">
|
|
{/* 행 선택 활성화 */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<p className="text-sm font-medium">행 선택 시에만 활성화</p>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
테이블에서 행을 선택해야만 버튼이 활성화돼요
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={config.action?.requireRowSelection || false}
|
|
onCheckedChange={(checked) => updateActionConfig("requireRowSelection", checked)}
|
|
/>
|
|
</div>
|
|
|
|
{config.action?.requireRowSelection && (
|
|
<div className="ml-4 space-y-2 border-l-2 border-primary/20 pl-3">
|
|
<div>
|
|
<Label className="text-xs">선택 데이터 소스</Label>
|
|
<Select
|
|
value={config.action?.rowSelectionSource || "auto"}
|
|
onValueChange={(v) => updateActionConfig("rowSelectionSource", v)}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="auto">자동 감지 (권장)</SelectItem>
|
|
<SelectItem value="tableList">테이블 리스트</SelectItem>
|
|
<SelectItem value="splitPanelLeft">분할 패널 좌측</SelectItem>
|
|
<SelectItem value="flowWidget">플로우 위젯</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs">다중 선택 허용</p>
|
|
<p className="text-[10px] text-muted-foreground">여러 행 선택 시에도 활성화</p>
|
|
</div>
|
|
<Switch
|
|
checked={config.action?.allowMultiRowSelection ?? true}
|
|
onCheckedChange={(checked) => updateActionConfig("allowMultiRowSelection", checked)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 데이터 전달 필드 매핑 (transferData 액션 전용) */}
|
|
{actionType === "transferData" && (
|
|
<>
|
|
<Separator />
|
|
<TransferDataFieldMappingSection
|
|
config={config}
|
|
onChange={onChange}
|
|
availableTables={availableTables}
|
|
mappingSourceColumnsMap={mappingSourceColumnsMap}
|
|
setMappingSourceColumnsMap={setMappingSourceColumnsMap}
|
|
mappingTargetColumns={mappingTargetColumns}
|
|
fieldMappingOpen={fieldMappingOpen}
|
|
setFieldMappingOpen={setFieldMappingOpen}
|
|
activeMappingGroupIndex={activeMappingGroupIndex}
|
|
setActiveMappingGroupIndex={setActiveMappingGroupIndex}
|
|
loadTableColumns={loadTableColumns}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* 제어 기능 */}
|
|
{actionType !== "excel_upload" && actionType !== "multi_table_excel_upload" && (
|
|
<>
|
|
<Separator />
|
|
<ImprovedButtonControlConfigPanel
|
|
component={componentData}
|
|
onUpdateProperty={handleUpdateProperty}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* 플로우 단계별 표시 제어 */}
|
|
{hasFlowWidget && (
|
|
<>
|
|
<Separator />
|
|
<FlowVisibilityConfigPanel
|
|
component={componentData}
|
|
allComponents={allComponents}
|
|
onUpdateProperty={handleUpdateProperty}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── 액션별 세부 설정 서브 컴포넌트 ───
|
|
const ActionDetailSection: React.FC<{
|
|
actionType: string;
|
|
config: Record<string, any>;
|
|
updateConfig: (field: string, value: any) => void;
|
|
updateActionConfig: (field: string, value: any) => void;
|
|
screens: ScreenOption[];
|
|
screensLoading: boolean;
|
|
modalScreenOpen: boolean;
|
|
setModalScreenOpen: (open: boolean) => void;
|
|
modalSearchTerm: string;
|
|
setModalSearchTerm: (term: string) => void;
|
|
currentTableName?: string;
|
|
allComponents?: ComponentData[];
|
|
handleUpdateProperty?: (path: string, value: any) => void;
|
|
}> = ({
|
|
actionType,
|
|
config,
|
|
updateConfig,
|
|
updateActionConfig,
|
|
screens,
|
|
screensLoading,
|
|
modalScreenOpen,
|
|
setModalScreenOpen,
|
|
modalSearchTerm,
|
|
setModalSearchTerm,
|
|
currentTableName,
|
|
allComponents = [],
|
|
handleUpdateProperty,
|
|
}) => {
|
|
const action = config.action || {};
|
|
|
|
// 성공/에러 메시지 (모든 액션 공통)
|
|
const commonMessageSection = (
|
|
<div className="space-y-2">
|
|
<div>
|
|
<Label className="text-xs">성공 메시지</Label>
|
|
<Input
|
|
value={action.successMessage || ""}
|
|
onChange={(e) => updateActionConfig("successMessage", e.target.value)}
|
|
placeholder="처리되었습니다."
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">에러 메시지</Label>
|
|
<Input
|
|
value={action.errorMessage || ""}
|
|
onChange={(e) => updateActionConfig("errorMessage", e.target.value)}
|
|
placeholder="처리 중 오류가 발생했습니다."
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
switch (actionType) {
|
|
case "save":
|
|
case "delete":
|
|
case "edit":
|
|
case "quickInsert":
|
|
return (
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Info className="h-4 w-4 text-primary" />
|
|
<span className="text-sm font-medium">
|
|
{actionType === "save" && "저장 설정"}
|
|
{actionType === "delete" && "삭제 설정"}
|
|
{actionType === "edit" && "편집 설정"}
|
|
{actionType === "quickInsert" && "즉시 저장 설정"}
|
|
</span>
|
|
</div>
|
|
{commonMessageSection}
|
|
|
|
{actionType === "delete" && (
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs font-medium">삭제 확인 팝업</p>
|
|
<p className="text-[10px] text-muted-foreground">삭제 전 확인 대화상자를 표시해요</p>
|
|
</div>
|
|
<Switch
|
|
checked={action.confirmBeforeDelete !== false}
|
|
onCheckedChange={(checked) => updateActionConfig("confirmBeforeDelete", checked)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
case "modal":
|
|
return (
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Maximize2 className="h-4 w-4 text-primary" />
|
|
<span className="text-sm font-medium">모달 설정</span>
|
|
</div>
|
|
|
|
{/* 대상 화면 선택 */}
|
|
<div>
|
|
<Label className="text-xs">대상 화면</Label>
|
|
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={modalScreenOpen}
|
|
className="h-8 w-full justify-between text-xs"
|
|
disabled={screensLoading}
|
|
>
|
|
{screensLoading
|
|
? "로딩 중..."
|
|
: action.targetScreenId
|
|
? screens.find((s) => s.id === action.targetScreenId)?.name || `화면 #${action.targetScreenId}`
|
|
: "화면 선택"}
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
|
<Command shouldFilter={false}>
|
|
<CommandInput
|
|
placeholder="화면 검색..."
|
|
value={modalSearchTerm}
|
|
onValueChange={setModalSearchTerm}
|
|
className="text-xs"
|
|
/>
|
|
<CommandList className="max-h-48">
|
|
<CommandEmpty className="py-3 text-xs">화면을 찾을 수 없습니다.</CommandEmpty>
|
|
<CommandGroup>
|
|
{screens
|
|
.filter((s) =>
|
|
!modalSearchTerm ||
|
|
s.name.toLowerCase().includes(modalSearchTerm.toLowerCase()) ||
|
|
s.description?.toLowerCase().includes(modalSearchTerm.toLowerCase()) ||
|
|
String(s.id).includes(modalSearchTerm)
|
|
)
|
|
.map((screen) => (
|
|
<CommandItem
|
|
key={screen.id}
|
|
value={String(screen.id)}
|
|
onSelect={() => {
|
|
updateActionConfig("targetScreenId", screen.id);
|
|
setModalScreenOpen(false);
|
|
setModalSearchTerm("");
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
action.targetScreenId === screen.id ? "opacity-100" : "opacity-0"
|
|
)}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{screen.name}</span>
|
|
{screen.description && (
|
|
<span className="text-[10px] text-muted-foreground">{screen.description}</span>
|
|
)}
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{/* 모달 제목/설명 */}
|
|
<div>
|
|
<Label className="text-xs">모달 제목</Label>
|
|
<Input
|
|
value={action.modalTitle || ""}
|
|
onChange={(e) => updateActionConfig("modalTitle", e.target.value)}
|
|
placeholder="모달 제목"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">모달 설명</Label>
|
|
<Input
|
|
value={action.modalDescription || ""}
|
|
onChange={(e) => updateActionConfig("modalDescription", e.target.value)}
|
|
placeholder="모달 설명"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
{/* 데이터 자동 전달 */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs font-medium">데이터 자동 전달</p>
|
|
<p className="text-[10px] text-muted-foreground">선택된 행의 데이터를 모달에 전달해요</p>
|
|
</div>
|
|
<Switch
|
|
checked={action.autoDetectDataSource || false}
|
|
onCheckedChange={(checked) => updateActionConfig("autoDetectDataSource", checked)}
|
|
/>
|
|
</div>
|
|
|
|
{commonMessageSection}
|
|
</div>
|
|
);
|
|
|
|
case "navigate":
|
|
return (
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<ArrowRight className="h-4 w-4 text-primary" />
|
|
<span className="text-sm font-medium">이동 설정</span>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">이동 대상 URL</Label>
|
|
<Input
|
|
value={action.targetUrl || ""}
|
|
onChange={(e) => updateActionConfig("targetUrl", e.target.value)}
|
|
placeholder="/admin/example"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
{commonMessageSection}
|
|
</div>
|
|
);
|
|
|
|
case "excel_download":
|
|
return (
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Download className="h-4 w-4 text-primary" />
|
|
<span className="text-sm font-medium">엑셀 다운로드 설정</span>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs font-medium">현재 필터 적용</p>
|
|
<p className="text-[10px] text-muted-foreground">검색 조건이 적용된 데이터만 다운로드</p>
|
|
</div>
|
|
<Switch
|
|
checked={action.applyCurrentFilters !== false}
|
|
onCheckedChange={(checked) => updateActionConfig("applyCurrentFilters", checked)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs font-medium">선택된 행만</p>
|
|
<p className="text-[10px] text-muted-foreground">테이블에서 선택한 행만 다운로드</p>
|
|
</div>
|
|
<Switch
|
|
checked={action.selectedRowsOnly || false}
|
|
onCheckedChange={(checked) => updateActionConfig("selectedRowsOnly", checked)}
|
|
/>
|
|
</div>
|
|
|
|
{commonMessageSection}
|
|
</div>
|
|
);
|
|
|
|
case "excel_upload":
|
|
case "multi_table_excel_upload":
|
|
return (
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Upload className="h-4 w-4 text-primary" />
|
|
<span className="text-sm font-medium">
|
|
{actionType === "multi_table_excel_upload" ? "다중 테이블 엑셀 업로드 설정" : "엑셀 업로드 설정"}
|
|
</span>
|
|
</div>
|
|
{commonMessageSection}
|
|
</div>
|
|
);
|
|
|
|
case "transferData":
|
|
return (
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<SendHorizontal className="h-4 w-4 text-primary" />
|
|
<span className="text-sm font-medium">데이터 전달 설정</span>
|
|
</div>
|
|
|
|
{/* 소스 컴포넌트 선택 */}
|
|
<div>
|
|
<Label className="text-xs">
|
|
소스 컴포넌트 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Select
|
|
value={action.dataTransfer?.sourceComponentId || ""}
|
|
onValueChange={(v) => {
|
|
const dt = { ...action.dataTransfer, sourceComponentId: v };
|
|
updateActionConfig("dataTransfer", dt);
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__auto__">
|
|
<span className="text-xs font-medium">자동 탐색 (현재 활성 테이블)</span>
|
|
</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;
|
|
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>
|
|
</div>
|
|
|
|
{/* 타겟 타입 */}
|
|
<div>
|
|
<Label className="text-xs">
|
|
타겟 타입 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Select
|
|
value={action.dataTransfer?.targetType || "component"}
|
|
onValueChange={(v) => {
|
|
const dt = { ...action.dataTransfer, targetType: v };
|
|
updateActionConfig("dataTransfer", dt);
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="component">같은 화면의 컴포넌트</SelectItem>
|
|
<SelectItem value="splitPanel">분할 패널 반대편 화면</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 타겟 컴포넌트 선택 */}
|
|
{action.dataTransfer?.targetType === "component" && (
|
|
<div>
|
|
<Label className="text-xs">
|
|
타겟 컴포넌트 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Select
|
|
value={action.dataTransfer?.targetComponentId || ""}
|
|
onValueChange={(v) => {
|
|
const dt = { ...action.dataTransfer, targetComponentId: v };
|
|
updateActionConfig("dataTransfer", dt);
|
|
}}
|
|
>
|
|
<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 !== action.dataTransfer?.sourceComponentId;
|
|
})
|
|
.map((comp: any) => {
|
|
const compType = comp.componentType || comp.type || "unknown";
|
|
const compLabel = comp.label || comp.componentConfig?.title || 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>
|
|
</div>
|
|
)}
|
|
|
|
{/* 데이터 전달 모드 */}
|
|
<div>
|
|
<Label className="text-xs">데이터 전달 모드</Label>
|
|
<Select
|
|
value={action.dataTransfer?.mode || "append"}
|
|
onValueChange={(v) => {
|
|
const dt = { ...action.dataTransfer, mode: v };
|
|
updateActionConfig("dataTransfer", dt);
|
|
}}
|
|
>
|
|
<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>
|
|
</div>
|
|
|
|
{/* 전달 후 초기화 */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs font-medium">전달 후 소스 선택 초기화</p>
|
|
<p className="text-[10px] text-muted-foreground">데이터 전달 후 소스의 선택을 해제해요</p>
|
|
</div>
|
|
<Switch
|
|
checked={action.dataTransfer?.clearAfterTransfer || false}
|
|
onCheckedChange={(checked) => {
|
|
const dt = { ...action.dataTransfer, clearAfterTransfer: checked };
|
|
updateActionConfig("dataTransfer", dt);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* 전달 전 확인 */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs font-medium">전달 전 확인 메시지</p>
|
|
<p className="text-[10px] text-muted-foreground">전달 전 확인 다이얼로그를 표시해요</p>
|
|
</div>
|
|
<Switch
|
|
checked={action.dataTransfer?.confirmBeforeTransfer || false}
|
|
onCheckedChange={(checked) => {
|
|
const dt = { ...action.dataTransfer, confirmBeforeTransfer: checked };
|
|
updateActionConfig("dataTransfer", dt);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{action.dataTransfer?.confirmBeforeTransfer && (
|
|
<div>
|
|
<Label className="text-xs">확인 메시지</Label>
|
|
<Input
|
|
value={action.dataTransfer?.confirmMessage || ""}
|
|
onChange={(e) => {
|
|
const dt = { ...action.dataTransfer, confirmMessage: e.target.value };
|
|
updateActionConfig("dataTransfer", dt);
|
|
}}
|
|
placeholder="선택한 항목을 전달하시겠습니까?"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{commonMessageSection}
|
|
</div>
|
|
);
|
|
|
|
case "event":
|
|
return (
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Send className="h-4 w-4 text-primary" />
|
|
<span className="text-sm font-medium">이벤트 설정</span>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">이벤트명</Label>
|
|
<Input
|
|
value={action.eventName || ""}
|
|
onChange={(e) => updateActionConfig("eventName", e.target.value)}
|
|
placeholder="이벤트 이름"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
{commonMessageSection}
|
|
</div>
|
|
);
|
|
|
|
default:
|
|
return (
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Settings className="h-4 w-4 text-primary" />
|
|
<span className="text-sm font-medium">기본 설정</span>
|
|
</div>
|
|
{commonMessageSection}
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
|
|
// ─── 아이콘 설정 서브 컴포넌트 ───
|
|
const IconSettingsSection: React.FC<{
|
|
actionType: string;
|
|
isNoIconAction: boolean;
|
|
currentActionIcons: string[];
|
|
selectedIcon: string;
|
|
selectedIconType: "lucide" | "svg";
|
|
iconSize: string;
|
|
displayMode: string;
|
|
iconTextPosition: string;
|
|
iconGap: number;
|
|
customIcons: string[];
|
|
customSvgIcons: Array<{ name: string; svg: string }>;
|
|
lucideSearchOpen: boolean;
|
|
setLucideSearchOpen: (open: boolean) => void;
|
|
lucideSearchTerm: string;
|
|
setLucideSearchTerm: (term: string) => void;
|
|
svgPasteOpen: boolean;
|
|
setSvgPasteOpen: (open: boolean) => void;
|
|
svgInput: string;
|
|
setSvgInput: (input: string) => void;
|
|
svgName: string;
|
|
setSvgName: (name: string) => void;
|
|
svgError: string;
|
|
setSvgError: (error: string) => void;
|
|
onSelectIcon: (name: string, type?: "lucide" | "svg") => void;
|
|
onRevertToDefault: () => void;
|
|
onIconSizeChange: (preset: string) => void;
|
|
onIconTextPositionChange: (pos: string) => void;
|
|
onIconGapChange: (gap: number) => void;
|
|
onCustomIconsChange: (icons: string[]) => void;
|
|
onCustomSvgIconsChange: (icons: Array<{ name: string; svg: string }>) => void;
|
|
}> = ({
|
|
actionType,
|
|
isNoIconAction,
|
|
currentActionIcons,
|
|
selectedIcon,
|
|
selectedIconType,
|
|
iconSize,
|
|
displayMode,
|
|
iconTextPosition,
|
|
iconGap,
|
|
customIcons,
|
|
customSvgIcons,
|
|
lucideSearchOpen,
|
|
setLucideSearchOpen,
|
|
lucideSearchTerm,
|
|
setLucideSearchTerm,
|
|
svgPasteOpen,
|
|
setSvgPasteOpen,
|
|
svgInput,
|
|
setSvgInput,
|
|
svgName,
|
|
setSvgName,
|
|
svgError,
|
|
setSvgError,
|
|
onSelectIcon,
|
|
onRevertToDefault,
|
|
onIconSizeChange,
|
|
onIconTextPositionChange,
|
|
onIconGapChange,
|
|
onCustomIconsChange,
|
|
onCustomSvgIconsChange,
|
|
}) => {
|
|
// 추천 아이콘 영역
|
|
const renderIconGrid = (icons: string[], type: "lucide" | "svg" = "lucide") => (
|
|
<div className="grid grid-cols-4 gap-1.5">
|
|
{icons.map((iconName) => {
|
|
const Icon = getLucideIcon(iconName);
|
|
if (!Icon) return null;
|
|
return (
|
|
<button
|
|
key={iconName}
|
|
type="button"
|
|
onClick={() => onSelectIcon(iconName, type)}
|
|
className={cn(
|
|
"hover:bg-muted flex flex-col items-center gap-1 rounded-md border p-2 transition-colors",
|
|
selectedIcon === iconName && selectedIconType === type
|
|
? "border-primary ring-primary/30 bg-primary/5 ring-2"
|
|
: "border-transparent"
|
|
)}
|
|
>
|
|
<Icon className="h-5 w-5" />
|
|
<span className="text-muted-foreground truncate text-[9px]">{iconName}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* 추천 아이콘 */}
|
|
{isNoIconAction ? (
|
|
<div className="text-muted-foreground rounded-md border border-dashed p-3 text-center text-xs">
|
|
{NO_ICON_MESSAGE}
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<p className="mb-1.5 text-xs font-medium">추천 아이콘</p>
|
|
{renderIconGrid(currentActionIcons)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 커스텀 아이콘 */}
|
|
{(customIcons.length > 0 || customSvgIcons.length > 0) && (
|
|
<>
|
|
<div className="flex items-center gap-2">
|
|
<div className="bg-border h-px flex-1" />
|
|
<span className="text-muted-foreground text-[10px]">커스텀 아이콘</span>
|
|
<div className="bg-border h-px flex-1" />
|
|
</div>
|
|
<div className="grid grid-cols-4 gap-1.5">
|
|
{customIcons.map((iconName) => {
|
|
const Icon = getLucideIcon(iconName);
|
|
if (!Icon) return null;
|
|
return (
|
|
<div key={`custom-${iconName}`} className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelectIcon(iconName, "lucide")}
|
|
className={cn(
|
|
"hover:bg-muted flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors",
|
|
selectedIcon === iconName && selectedIconType === "lucide"
|
|
? "border-primary ring-primary/30 bg-primary/5 ring-2"
|
|
: "border-transparent"
|
|
)}
|
|
>
|
|
<Icon className="h-5 w-5" />
|
|
<span className="text-muted-foreground truncate text-[9px]">{iconName}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onCustomIconsChange(customIcons.filter((n) => n !== iconName));
|
|
if (selectedIcon === iconName) onRevertToDefault();
|
|
}}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/80 absolute -top-1 -right-1 rounded-full p-0.5"
|
|
>
|
|
<X className="h-2.5 w-2.5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
{customSvgIcons.map((svgIcon) => (
|
|
<div key={`svg-${svgIcon.name}`} className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelectIcon(svgIcon.name, "svg")}
|
|
className={cn(
|
|
"hover:bg-muted flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors",
|
|
selectedIcon === svgIcon.name && selectedIconType === "svg"
|
|
? "border-primary ring-primary/30 bg-primary/5 ring-2"
|
|
: "border-transparent"
|
|
)}
|
|
>
|
|
<span
|
|
className="flex h-5 w-5 items-center justify-center [&>svg]:h-full [&>svg]:w-full"
|
|
dangerouslySetInnerHTML={{ __html: sanitizeSvg(svgIcon.svg) }}
|
|
/>
|
|
<span className="text-muted-foreground truncate text-[9px]">{svgIcon.name}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onCustomSvgIconsChange(customSvgIcons.filter((s) => s.name !== svgIcon.name));
|
|
if (selectedIcon === svgIcon.name) onRevertToDefault();
|
|
}}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/80 absolute -top-1 -right-1 rounded-full p-0.5"
|
|
>
|
|
<X className="h-2.5 w-2.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* 커스텀 아이콘 추가 */}
|
|
<div className="flex gap-2">
|
|
<Popover open={lucideSearchOpen} onOpenChange={setLucideSearchOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="outline" size="sm" className="h-7 flex-1 text-xs">
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
Lucide 검색
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-64 p-0" align="start">
|
|
<Command>
|
|
<CommandInput
|
|
placeholder="아이콘 이름 검색..."
|
|
value={lucideSearchTerm}
|
|
onValueChange={setLucideSearchTerm}
|
|
className="text-xs"
|
|
/>
|
|
<CommandList className="max-h-48">
|
|
<CommandEmpty className="py-3 text-xs">아이콘을 찾을 수 없습니다.</CommandEmpty>
|
|
<CommandGroup>
|
|
{Object.keys(allLucideIcons)
|
|
.filter((name) => name.toLowerCase().includes(lucideSearchTerm.toLowerCase()))
|
|
.slice(0, 30)
|
|
.map((iconName) => {
|
|
const Icon = allLucideIcons[iconName as keyof typeof allLucideIcons];
|
|
return (
|
|
<CommandItem
|
|
key={iconName}
|
|
value={iconName}
|
|
onSelect={() => {
|
|
const next = [...customIcons];
|
|
if (!next.includes(iconName)) {
|
|
next.push(iconName);
|
|
onCustomIconsChange(next);
|
|
if (Icon) addToIconMap(iconName, Icon);
|
|
}
|
|
setLucideSearchOpen(false);
|
|
setLucideSearchTerm("");
|
|
}}
|
|
className="flex items-center gap-2 text-xs"
|
|
>
|
|
{Icon ? <Icon className="h-4 w-4" /> : <span className="h-4 w-4" />}
|
|
{iconName}
|
|
{customIcons.includes(iconName) && <Check className="text-primary ml-auto h-3 w-3" />}
|
|
</CommandItem>
|
|
);
|
|
})}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
<Popover open={svgPasteOpen} onOpenChange={setSvgPasteOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="outline" size="sm" className="h-7 flex-1 text-xs">
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
SVG 붙여넣기
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-72 space-y-2 p-3" align="start">
|
|
<Label className="text-xs">아이콘 이름</Label>
|
|
<Input
|
|
value={svgName}
|
|
onChange={(e) => setSvgName(e.target.value)}
|
|
placeholder="예: 회사로고"
|
|
className="h-7 text-xs"
|
|
/>
|
|
<Label className="text-xs">SVG 코드</Label>
|
|
<textarea
|
|
value={svgInput}
|
|
onChange={(e) => {
|
|
setSvgInput(e.target.value);
|
|
setSvgError("");
|
|
}}
|
|
onPaste={(e) => {
|
|
e.stopPropagation();
|
|
const text = e.clipboardData.getData("text/plain");
|
|
if (text) {
|
|
e.preventDefault();
|
|
setSvgInput(text);
|
|
setSvgError("");
|
|
}
|
|
}}
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
placeholder={'<svg xmlns="http://www.w3.org/2000/svg" ...>...</svg>'}
|
|
className="bg-background focus:ring-ring h-20 w-full rounded-md border px-2 py-1.5 text-xs focus:ring-2 focus:outline-none"
|
|
/>
|
|
{svgInput && (
|
|
<div className="bg-muted/50 flex items-center justify-center rounded border p-2">
|
|
<span
|
|
className="flex h-8 w-8 items-center justify-center [&>svg]:h-full [&>svg]:w-full"
|
|
dangerouslySetInnerHTML={{ __html: sanitizeSvg(svgInput) }}
|
|
/>
|
|
</div>
|
|
)}
|
|
{svgError && <p className="text-destructive text-xs">{svgError}</p>}
|
|
<Button
|
|
size="sm"
|
|
className="h-7 w-full text-xs"
|
|
onClick={() => {
|
|
if (!svgName.trim()) {
|
|
setSvgError("아이콘 이름을 입력하세요.");
|
|
return;
|
|
}
|
|
if (!svgInput.trim().includes("<svg")) {
|
|
setSvgError("유효한 SVG 코드가 아닙니다.");
|
|
return;
|
|
}
|
|
const sanitized = sanitizeSvg(svgInput);
|
|
let finalName = svgName.trim();
|
|
const existingNames = new Set(customSvgIcons.map((s) => s.name));
|
|
if (existingNames.has(finalName)) {
|
|
let counter = 2;
|
|
while (existingNames.has(`${svgName.trim()}(${counter})`)) counter++;
|
|
finalName = `${svgName.trim()}(${counter})`;
|
|
}
|
|
onCustomSvgIconsChange([...customSvgIcons, { name: finalName, svg: sanitized }]);
|
|
setSvgInput("");
|
|
setSvgName("");
|
|
setSvgError("");
|
|
setSvgPasteOpen(false);
|
|
}}
|
|
>
|
|
추가
|
|
</Button>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{/* 아이콘 크기 */}
|
|
<div>
|
|
<Label className="mb-1.5 text-xs">아이콘 크기</Label>
|
|
<div className="flex rounded-md border">
|
|
{Object.keys(iconSizePresets).map((preset) => (
|
|
<button
|
|
key={preset}
|
|
type="button"
|
|
onClick={() => onIconSizeChange(preset)}
|
|
className={cn(
|
|
"flex-1 px-1 py-1 text-xs font-medium whitespace-nowrap transition-colors first:rounded-l-md last:rounded-r-md",
|
|
iconSize === preset
|
|
? "bg-primary text-primary-foreground"
|
|
: "hover:bg-muted text-muted-foreground"
|
|
)}
|
|
>
|
|
{preset}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 텍스트 위치 (icon-text 모드) */}
|
|
{displayMode === "icon-text" && (
|
|
<>
|
|
<div>
|
|
<Label className="mb-1.5 text-xs">텍스트 위치</Label>
|
|
<div className="flex rounded-md border">
|
|
{([
|
|
{ value: "left", label: "왼쪽" },
|
|
{ value: "right", label: "오른쪽" },
|
|
{ value: "top", label: "위쪽" },
|
|
{ value: "bottom", label: "아래쪽" },
|
|
] as const).map((pos) => (
|
|
<button
|
|
key={pos.value}
|
|
type="button"
|
|
onClick={() => onIconTextPositionChange(pos.value)}
|
|
className={cn(
|
|
"flex-1 px-2 py-1 text-xs font-medium transition-colors first:rounded-l-md last:rounded-r-md",
|
|
iconTextPosition === pos.value
|
|
? "bg-primary text-primary-foreground"
|
|
: "hover:bg-muted text-muted-foreground"
|
|
)}
|
|
>
|
|
{pos.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="mb-1.5 text-xs">아이콘-텍스트 간격</Label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={32}
|
|
step={1}
|
|
value={Math.min(iconGap, 32)}
|
|
onChange={(e) => onIconGapChange(Number(e.target.value))}
|
|
className="accent-primary h-1.5 flex-1 cursor-pointer"
|
|
/>
|
|
<div className="flex items-center gap-1">
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
value={iconGap}
|
|
onChange={(e) => onIconGapChange(Math.max(0, Number(e.target.value) || 0))}
|
|
className="h-7 w-14 text-center text-xs"
|
|
/>
|
|
<span className="text-muted-foreground text-xs">px</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── 데이터 전달 필드 매핑 서브 컴포넌트 (고급 설정 내부) ───
|
|
const TransferDataFieldMappingSection: React.FC<{
|
|
config: Record<string, any>;
|
|
onChange: (config: Record<string, any>) => void;
|
|
availableTables: Array<{ name: string; label: string }>;
|
|
mappingSourceColumnsMap: Record<string, Array<{ name: string; label: string }>>;
|
|
setMappingSourceColumnsMap: React.Dispatch<React.SetStateAction<Record<string, Array<{ name: string; label: string }>>>>;
|
|
mappingTargetColumns: Array<{ name: string; label: string }>;
|
|
fieldMappingOpen: boolean;
|
|
setFieldMappingOpen: (open: boolean) => void;
|
|
activeMappingGroupIndex: number;
|
|
setActiveMappingGroupIndex: (index: number) => void;
|
|
loadTableColumns: (tableName: string) => Promise<Array<{ name: string; label: string }>>;
|
|
}> = ({
|
|
config,
|
|
onChange,
|
|
availableTables,
|
|
mappingSourceColumnsMap,
|
|
setMappingSourceColumnsMap,
|
|
mappingTargetColumns,
|
|
activeMappingGroupIndex,
|
|
setActiveMappingGroupIndex,
|
|
loadTableColumns,
|
|
}) => {
|
|
const [sourcePopoverOpen, setSourcePopoverOpen] = useState<Record<string, boolean>>({});
|
|
const [targetPopoverOpen, setTargetPopoverOpen] = useState<Record<string, boolean>>({});
|
|
|
|
const dataTransfer = config.action?.dataTransfer || {};
|
|
const multiTableMappings: Array<{ sourceTable: string; mappingRules: Array<{ sourceField: string; targetField: string }> }> =
|
|
dataTransfer.multiTableMappings || [];
|
|
|
|
const updateDataTransfer = (field: string, value: any) => {
|
|
const currentAction = config.action || {};
|
|
const currentDt = currentAction.dataTransfer || {};
|
|
onChange({
|
|
...config,
|
|
action: {
|
|
...currentAction,
|
|
dataTransfer: { ...currentDt, [field]: value },
|
|
},
|
|
});
|
|
};
|
|
|
|
const activeGroup = multiTableMappings[activeMappingGroupIndex];
|
|
const activeSourceTable = activeGroup?.sourceTable || "";
|
|
const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || [];
|
|
const activeRules = activeGroup?.mappingRules || [];
|
|
|
|
const updateGroupField = (field: string, value: any) => {
|
|
const mappings = [...multiTableMappings];
|
|
mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value };
|
|
updateDataTransfer("multiTableMappings", mappings);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="space-y-0.5">
|
|
<p className="text-sm font-medium">필드 매핑</p>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
레이어별로 소스 테이블이 다를 때 각각 매핑 규칙을 설정해요
|
|
</p>
|
|
</div>
|
|
|
|
{/* 타겟 테이블 (공통) */}
|
|
<div>
|
|
<Label className="text-xs">타겟 테이블 (공통)</Label>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
|
{dataTransfer.targetTable
|
|
? availableTables.find((t) => t.name === dataTransfer.targetTable)?.label ||
|
|
dataTransfer.targetTable
|
|
: "타겟 테이블 선택"}
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 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 className="max-h-48">
|
|
<CommandEmpty className="py-3 text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
|
<CommandGroup>
|
|
{availableTables.map((table) => (
|
|
<CommandItem
|
|
key={table.name}
|
|
value={`${table.label} ${table.name}`}
|
|
onSelect={() => updateDataTransfer("targetTable", table.name)}
|
|
className="text-xs"
|
|
>
|
|
<Check className={cn("mr-2 h-3 w-3", dataTransfer.targetTable === table.name ? "opacity-100" : "opacity-0")} />
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{table.label}</span>
|
|
{table.label !== table.name && <span className="text-[10px] text-muted-foreground">{table.name}</span>}
|
|
</div>
|
|
</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-7 text-xs"
|
|
onClick={() => {
|
|
updateDataTransfer("multiTableMappings", [
|
|
...multiTableMappings,
|
|
{ sourceTable: "", mappingRules: [] },
|
|
]);
|
|
setActiveMappingGroupIndex(multiTableMappings.length);
|
|
}}
|
|
disabled={!dataTransfer.targetTable}
|
|
>
|
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
|
소스 테이블 추가
|
|
</Button>
|
|
</div>
|
|
|
|
{!dataTransfer.targetTable ? (
|
|
<div className="rounded-lg border border-dashed p-4 text-center">
|
|
<p className="text-xs text-muted-foreground">타겟 테이블을 먼저 선택하세요</p>
|
|
</div>
|
|
) : multiTableMappings.length === 0 ? (
|
|
<div className="rounded-lg border border-dashed p-4 text-center">
|
|
<p className="text-xs text-muted-foreground">소스 테이블을 추가하세요</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{/* 그룹 탭 */}
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{multiTableMappings.map((group, gIdx) => (
|
|
<div key={gIdx} className="flex items-center">
|
|
<Button
|
|
type="button"
|
|
variant={activeMappingGroupIndex === gIdx ? "default" : "outline"}
|
|
size="sm"
|
|
className="h-7 text-xs rounded-r-none"
|
|
onClick={() => setActiveMappingGroupIndex(gIdx)}
|
|
>
|
|
{group.sourceTable
|
|
? availableTables.find((t) => t.name === group.sourceTable)?.label || group.sourceTable
|
|
: `그룹 ${gIdx + 1}`}
|
|
{group.mappingRules?.length > 0 && (
|
|
<span className="ml-1.5 rounded-full bg-primary-foreground/20 px-1.5 text-[10px]">
|
|
{group.mappingRules.length}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant={activeMappingGroupIndex === gIdx ? "default" : "outline"}
|
|
size="icon"
|
|
className="h-7 w-7 rounded-l-none border-l-0"
|
|
onClick={() => {
|
|
const mappings = [...multiTableMappings];
|
|
mappings.splice(gIdx, 1);
|
|
updateDataTransfer("multiTableMappings", mappings);
|
|
if (activeMappingGroupIndex >= mappings.length) {
|
|
setActiveMappingGroupIndex(Math.max(0, mappings.length - 1));
|
|
}
|
|
}}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 활성 그룹 편집 */}
|
|
{activeGroup && (
|
|
<div className="space-y-3 rounded-lg border p-3">
|
|
{/* 소스 테이블 선택 */}
|
|
<div>
|
|
<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="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
|
<Command>
|
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
|
<CommandList className="max-h-48">
|
|
<CommandEmpty className="py-3 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 loadTableColumns(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")} />
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{table.label}</span>
|
|
{table.label !== table.name && <span className="text-[10px] text-muted-foreground">{table.name}</span>}
|
|
</div>
|
|
</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-7 text-xs"
|
|
onClick={() => {
|
|
updateGroupField("mappingRules", [
|
|
...activeRules,
|
|
{ sourceField: "", targetField: "" },
|
|
]);
|
|
}}
|
|
disabled={!activeSourceTable}
|
|
>
|
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
|
|
{!activeSourceTable ? (
|
|
<p className="text-xs text-muted-foreground">소스 테이블을 먼저 선택하세요</p>
|
|
) : activeRules.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">매핑 없음 (동일 필드명 자동 매핑)</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{activeRules.map((rule: any, rIdx: number) => {
|
|
const keyS = `${activeMappingGroupIndex}-${rIdx}-s`;
|
|
const keyT = `${activeMappingGroupIndex}-${rIdx}-t`;
|
|
return (
|
|
<div
|
|
key={rIdx}
|
|
className="grid items-center gap-1.5"
|
|
style={{ gridTemplateColumns: "1fr 16px 1fr 32px" }}
|
|
>
|
|
{/* 소스 필드 */}
|
|
<Popover
|
|
open={sourcePopoverOpen[keyS] || false}
|
|
onOpenChange={(open) => setSourcePopoverOpen((prev) => ({ ...prev, [keyS]: open }))}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs overflow-hidden">
|
|
<span className="truncate">
|
|
{rule.sourceField
|
|
? activeSourceColumns.find((c) => c.name === rule.sourceField)?.label || rule.sourceField
|
|
: "소스 컬럼"}
|
|
</span>
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[220px] p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
|
<CommandList className="max-h-48">
|
|
<CommandEmpty className="py-3 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);
|
|
setSourcePopoverOpen((prev) => ({ ...prev, [keyS]: false }));
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check className={cn("mr-2 h-3 w-3", rule.sourceField === col.name ? "opacity-100" : "opacity-0")} />
|
|
<span className="font-medium">{col.label}</span>
|
|
{col.label !== col.name && <span className="ml-1 text-muted-foreground">({col.name})</span>}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
<ArrowRight className="mx-auto h-4 w-4 text-muted-foreground" />
|
|
|
|
{/* 타겟 필드 */}
|
|
<Popover
|
|
open={targetPopoverOpen[keyT] || false}
|
|
onOpenChange={(open) => setTargetPopoverOpen((prev) => ({ ...prev, [keyT]: open }))}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs overflow-hidden">
|
|
<span className="truncate">
|
|
{rule.targetField
|
|
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label || rule.targetField
|
|
: "타겟 컬럼"}
|
|
</span>
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[220px] p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
|
<CommandList className="max-h-48">
|
|
<CommandEmpty className="py-3 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);
|
|
setTargetPopoverOpen((prev) => ({ ...prev, [keyT]: false }));
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check className={cn("mr-2 h-3 w-3", rule.targetField === col.name ? "opacity-100" : "opacity-0")} />
|
|
<span className="font-medium">{col.label}</span>
|
|
{col.label !== col.name && <span className="ml-1 text-muted-foreground">({col.name})</span>}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* 삭제 */}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
|
onClick={() => {
|
|
const newRules = [...activeRules];
|
|
newRules.splice(rIdx, 1);
|
|
updateGroupField("mappingRules", newRules);
|
|
}}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
V2ButtonConfigPanel.displayName = "V2ButtonConfigPanel";
|
|
|
|
export default V2ButtonConfigPanel;
|