ERP-node/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx

2036 lines
80 KiB
TypeScript
Raw Normal View History

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