Files
wace_rps/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx
T
kjs b4a5fb9aa3 feat: enhance ButtonConfigPanel and V2ButtonConfigPanel with improved data handling
- Updated the ButtonConfigPanel to fetch a larger set of screens by modifying the API call to include a size parameter.
- Enhanced the V2ButtonConfigPanel with new state variables and effects for managing data transfer field mappings, including loading available tables and their columns.
- Implemented multi-table mapping logic to support complex data transfer actions, improving the flexibility and usability of the component.
- Added a dedicated section for field mapping in the UI, allowing users to configure data transfer settings more effectively.

These updates aim to enhance the functionality and user experience of the button configuration panels within the ERP system, enabling better data management and transfer capabilities.

Made-with: Cursor
2026-03-16 16:47:33 +09:00

2048 lines
80 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: "quickInsert",
icon: Zap,
title: "즉시 저장",
description: "바로 저장해요",
},
{
value: "approval",
icon: Check,
title: "결재 요청",
description: "결재를 요청해요",
},
{
value: "control",
icon: Settings,
title: "제어 흐름",
description: "흐름을 제어해요",
},
{
value: "event",
icon: Send,
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);
setMappingTargetColumns(cols);
} else {
setMappingTargetColumns([]);
}
};
loadAll();
}, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, 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;