Files
invyone/frontend/components/v2/config-panels/InvLegacyButtonConfigPanel.tsx
T
DDD1542 7d204bfffd
Build & Deploy to K8s / build-and-deploy (push) Failing after 14m3s
refactor: complete canonical table cleanup
2026-05-21 11:55:08 +09:00

1723 lines
60 KiB
TypeScript

"use client";
/**
* InvLegacyButtonConfigPanel — 버튼 (v2-button-primary) cp 톤 설정 패널
*
* 흐름:
* ① 액션 유형 (CPVisualGrid 14종 시각 카드)
* ② 표시 모드 (CPVisualGrid 3종 — text/icon/icon-text)
* ③ 텍스트 + 변형 (CPSegment)
* ④ 액션별 세부 설정 (FormatBody 디스패처 — 10+ case)
* ▾ 아이콘 설정 (조건부 — icon/icon-text 일 때)
* ▾ 고급 설정 (행 선택 / 데이터 매핑 / 제어 흐름 / 흐름 가시성)
*
* V2ButtonConfigPanel(2212줄) 폐기 → cp 톤 신규.
*
* Reference: notes/gbpark/2026-04-28-cp-panel-standard.md
*/
import React, { useState, useEffect, useMemo, useCallback } from "react";
import {
Save,
Trash2,
Pencil,
ArrowRight,
Maximize2,
SendHorizontal,
Download,
Upload,
Check,
Plus,
X,
Type,
Image as ImageIcon,
Columns,
ScanLine,
Truck,
Send,
Copy,
FileSpreadsheet,
Settings,
Workflow,
Info,
} from "lucide-react";
import { icons as allLucideIcons } from "lucide-react";
import {
CPSection,
CPRow,
CPGroup,
CPText,
CPSelect,
CPSwitch,
CPSegment,
CPNumber,
CPIconBtn,
CPVisualGrid,
FeatureChipGrid,
Hint,
SectionLabel,
InlineLoader,
} from "./_shared/cp";
import { apiClient } from "@/lib/api/client";
import {
actionIconMap,
noIconActions,
NO_ICON_MESSAGE,
iconSizePresets,
getLucideIcon,
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";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
// ───────────────────────────────────────────────────────
// 상수: 액션 / 표시 / 변형
// ───────────────────────────────────────────────────────
const ACTION_TYPE_CARDS = [
{ value: "save", icon: Save, title: "저장", desc: "데이터 저장" },
{ value: "delete", icon: Trash2, title: "삭제", desc: "데이터 삭제" },
{ value: "edit", icon: Pencil, title: "편집", desc: "데이터 수정" },
{ value: "modal", icon: Maximize2, title: "모달", desc: "팝업 열기" },
{ value: "navigate", icon: ArrowRight, title: "이동", desc: "다른 화면" },
{ value: "transferData", icon: SendHorizontal, title: "전달", desc: "다른 테이블로" },
{ value: "excel_download", icon: Download, title: "엑셀 ⇣", desc: "엑셀 다운" },
{ value: "excel_upload", icon: Upload, title: "엑셀 ⇡", desc: "엑셀 업로드" },
{ value: "approval", icon: Check, title: "결재", desc: "결재 요청" },
{ value: "control", icon: Settings, title: "제어", desc: "흐름 제어" },
{ value: "copy", icon: Copy, title: "복사", desc: "데이터 복사" },
{ value: "barcode_scan", icon: ScanLine, title: "바코드", desc: "스캔" },
{ value: "operation_control", icon: Truck, title: "운행", desc: "운행 관리" },
{ value: "multi_table_excel_upload", icon: FileSpreadsheet, title: "다중 ⇡", desc: "여러 테이블" },
] as const;
const DISPLAY_MODE_CARDS = [
{ value: "text", icon: Type, title: "텍스트" },
{ value: "icon", icon: ImageIcon, title: "아이콘" },
{ value: "icon-text", icon: Columns, title: "아이콘+텍스트" },
] as const;
const VARIANT_OPTIONS = [
{ value: "primary", label: "기본" },
{ value: "secondary", label: "보조" },
{ value: "danger", label: "위험" },
] as const;
const MODAL_SIZE_OPTIONS = [
{ value: "sm", label: "작게" },
{ value: "md", label: "보통" },
{ value: "lg", label: "크게" },
{ value: "xl", label: "더 크게" },
{ value: "full", label: "전체" },
] as const;
// canonical table / data-table / datatable 등 table-like 컴포넌트
// 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송
// 호환 대상으로 함께 인식.
const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const;
const isDataTransferComponentType = (typeValue: unknown): boolean => {
if (isTableLikeComponentType(typeValue)) return true;
if (typeof typeValue !== "string") return false;
return DATA_TRANSFER_EXTRA_PATTERNS.some((t) => typeValue.includes(t));
};
const TRANSFER_MODE_OPTIONS = [
{ value: "append", label: "추가" },
{ value: "replace", label: "교체" },
{ value: "merge", label: "병합" },
] as const;
const ICON_TEXT_POSITION_OPTIONS = [
{ value: "left", label: "왼쪽" },
{ value: "right", label: "오른쪽" },
{ value: "top", label: "위" },
{ value: "bottom", label: "아래" },
] as const;
interface ScreenOption {
id: number;
name: string;
description?: string;
}
interface InvLegacyButtonConfigPanelProps {
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;
}
// ───────────────────────────────────────────────────────
// Main Component
// ───────────────────────────────────────────────────────
export const InvLegacyButtonConfigPanel: React.FC<InvLegacyButtonConfigPanelProps> = ({
config,
onChange,
component,
currentComponent,
allComponents = [],
currentTableName,
screenTableName,
}) => {
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 : "버튼";
// ── State ────────────────────────────────────────────
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 || "보통");
const [screens, setScreens] = useState<ScreenOption[]>([]);
const [screensLoading, setScreensLoading] = useState(false);
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 [activeMappingGroupIndex, setActiveMappingGroupIndex] = useState(0);
const [lucideSearchTerm, setLucideSearchTerm] = useState("");
const [svgInput, setSvgInput] = useState("");
const [svgName, setSvgName] = useState("");
const [svgError, setSvgError] = useState("");
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]);
// ── Helpers ──────────────────────────────────────────
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],
);
const updateAction = useCallback(
(key: string, value: boolean) => updateActionConfig(key, value),
[updateActionConfig],
);
const handleUpdateProperty = useCallback(
(path: string, value: any) => {
const normalized = path.replace(/^componentConfig\./, "").replace(/^webTypeConfig\./, "");
const parts = normalized.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],
);
useEffect(() => {
setSelectedIcon(config.icon?.name || "");
setSelectedIconType(config.icon?.type || "lucide");
setIconSize(config.icon?.size || "보통");
}, [config.icon?.name, config.icon?.type, config.icon?.size]);
// 화면 목록 로드 (modal/edit/navigate)
useEffect(() => {
if (actionType !== "modal" && actionType !== "navigate" && actionType !== "edit") return;
if (screens.length > 0) return;
const load = async () => {
setScreensLoading(true);
try {
const r = await apiClient.get("/screen-management/screens?size=1000");
if (r.data.success && r.data.data) {
setScreens(
r.data.data.map((s: any) => ({
id: s.id || s.screen_id,
name: s.name || s.screen_name,
description: s.description || "",
})),
);
}
} catch {
setScreens([]);
} finally {
setScreensLoading(false);
}
};
load();
}, [actionType, screens.length]);
// 테이블 목록 로드 (transferData)
useEffect(() => {
if (actionType !== "transferData") return;
if (availableTables.length > 0) return;
const load = async () => {
try {
const r = await apiClient.get("/table-management/tables");
if (r.data.success && r.data.data) {
setAvailableTables(
r.data.data.map((t: any) => ({
name: t.table_name || t.name,
label: t.display_name || t.table_label || t.label || t.table_name || t.name,
})),
);
}
} catch {
setAvailableTables([]);
}
};
load();
}, [actionType, availableTables.length]);
const loadTableColumns = useCallback(
async (tableName: string): Promise<Array<{ name: string; label: string }>> => {
try {
const r = await apiClient.get(`/table-management/tables/${tableName}/columns`);
if (r.data.success) {
let data = r.data.data;
if (!Array.isArray(data) && data?.columns) data = data.columns;
if (!Array.isArray(data) && data?.data) data = data.data;
if (Array.isArray(data)) {
return data.map((c: any) => ({
name: c.name || c.column_name,
label: c.display_name || c.label || c.column_label || c.name || c.column_name,
}));
}
}
} 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,
]);
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);
}
};
const componentData = useMemo(() => {
if (effectiveComponent) {
return {
...effectiveComponent,
componentConfig: config,
webTypeConfig: config,
} as unknown 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 unknown as ComponentData;
}, [effectiveComponent, config]);
// ── Render ───────────────────────────────────────────
return (
<div style={{ fontFamily: "var(--v5-font-sans)", color: "var(--cp-text)", padding: "0 12px" }}>
{/* ── ① 액션 유형 ─────────────────────────── */}
<CPSection title="① 액션 유형" desc="이 버튼이 무엇을 하는가">
<CPVisualGrid
cols={3}
cardHeight={66}
value={actionType}
onChange={(v) => handleActionTypeChange(v)}
options={ACTION_TYPE_CARDS.map((c) => {
const Icon = c.icon;
return {
value: c.value,
label: c.title,
desc: c.desc,
preview: <Icon size={16} />,
};
})}
/>
</CPSection>
{/* ── ② 표시 모드 ─────────────────────────── */}
<CPSection title="② 표시 모드" desc="버튼 형태">
<CPVisualGrid
cols={3}
cardHeight={54}
value={displayMode}
onChange={(v) => {
updateConfig("displayMode", v);
if ((v === "icon" || v === "icon-text") && !selectedIcon) {
revertToDefaultIcon();
}
}}
options={DISPLAY_MODE_CARDS.map((c) => {
const Icon = c.icon;
return { value: c.value, label: c.title, preview: <Icon size={14} /> };
})}
/>
</CPSection>
{/* ── ③ 텍스트 + 변형 ─────────────────────────── */}
<CPSection title="③ 텍스트 / 스타일">
{(displayMode === "text" || displayMode === "icon-text") && (
<CPRow label="버튼 텍스트">
<CPText
value={buttonText}
onChange={(v) => updateConfig("text", v)}
placeholder="버튼"
/>
</CPRow>
)}
<CPRow label="스타일">
<CPSegment
value={variant}
onChange={(v) => updateConfig("variant", v)}
options={VARIANT_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
/>
</CPRow>
</CPSection>
{/* ── ④ 액션별 세부 설정 ─────────────────────────── */}
<CPSection title="④ 액션별 설정" desc={describeAction(actionType)}>
<ActionDetailBody
actionType={actionType}
config={config}
updateActionConfig={updateActionConfig}
screens={screens}
screensLoading={screensLoading}
allComponents={allComponents}
/>
</CPSection>
{/* ── ▾ 아이콘 설정 ─────────────────────────── */}
{showIconSettings && (
<CPGroup title="아이콘 설정" defaultOpen={false}>
<IconOptionsBody
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}
lucideSearchTerm={lucideSearchTerm}
setLucideSearchTerm={setLucideSearchTerm}
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)}
/>
</CPGroup>
)}
{/* ── ▾ 고급 설정 ─────────────────────────── */}
<CPGroup title="고급 설정" defaultOpen={false}>
{/* 행 선택 활성화 */}
<CPRow label="행 선택 활성화" help="행을 선택해야만 버튼이 활성화">
<CPSwitch
value={config.action?.requireRowSelection || false}
onChange={(v) => updateAction("requireRowSelection", v)}
/>
</CPRow>
{config.action?.requireRowSelection && (
<div style={{ marginLeft: 8, paddingLeft: 10, borderLeft: "2px solid rgba(var(--v5-primary-rgb), 0.3)" }}>
<CPRow label="데이터 소스">
<CPSelect
value={config.action?.rowSelectionSource || "auto"}
onChange={(v) => updateActionConfig("rowSelectionSource", v)}
searchable={false}
>
<option value="auto"> ()</option>
<option value="tableList"> </option>
<option value="splitPanelLeft"> </option>
<option value="flowWidget"> </option>
</CPSelect>
</CPRow>
<CPRow label="다중 선택 허용" help="여러 행 선택 시에도 활성화">
<CPSwitch
value={config.action?.allowMultiRowSelection ?? true}
onChange={(v) => updateAction("allowMultiRowSelection", v)}
/>
</CPRow>
</div>
)}
{/* 데이터 전달 매핑 (transferData 일 때) */}
{actionType === "transferData" && (
<div style={{ marginTop: 10 }}>
<TransferDataMappingBody
config={config}
onChange={onChange}
availableTables={availableTables}
mappingSourceColumnsMap={mappingSourceColumnsMap}
setMappingSourceColumnsMap={setMappingSourceColumnsMap}
mappingTargetColumns={mappingTargetColumns}
activeMappingGroupIndex={activeMappingGroupIndex}
setActiveMappingGroupIndex={setActiveMappingGroupIndex}
loadTableColumns={loadTableColumns}
/>
</div>
)}
{/* 제어 흐름 (excel_upload / multi_table_excel_upload 제외) */}
{actionType !== "excel_upload" && actionType !== "multi_table_excel_upload" && (
<div style={{ marginTop: 10, paddingTop: 8, borderTop: "1px solid var(--cp-border-subtle)" }}>
<SectionLabel icon={<Settings size={11} />} text="제어 흐름" />
<ImprovedButtonControlConfigPanel
component={componentData}
onUpdateProperty={handleUpdateProperty}
/>
</div>
)}
{/* 흐름 가시성 (flow widget 있을 때) */}
{hasFlowWidget && (
<div style={{ marginTop: 10, paddingTop: 8, borderTop: "1px solid var(--cp-border-subtle)" }}>
<SectionLabel icon={<Workflow size={11} />} text="플로우 단계별 표시 제어" primary />
<FlowVisibilityConfigPanel
component={componentData}
allComponents={allComponents}
onUpdateProperty={handleUpdateProperty}
/>
</div>
)}
</CPGroup>
</div>
);
};
InvLegacyButtonConfigPanel.displayName = "InvLegacyButtonConfigPanel";
// ───────────────────────────────────────────────────────
// describeAction — ④ 섹션 desc 동적 텍스트
// ───────────────────────────────────────────────────────
function describeAction(t: string): string {
const card = ACTION_TYPE_CARDS.find((c) => c.value === t);
return card ? `${card.title}${card.desc}` : "선택한 액션의 옵션";
}
// ───────────────────────────────────────────────────────
// ActionDetailBody — 액션별 세부 설정 (10+ case)
// ───────────────────────────────────────────────────────
interface ActionDetailBodyProps {
actionType: string;
config: Record<string, any>;
updateActionConfig: (field: string, value: any) => void;
screens: ScreenOption[];
screensLoading: boolean;
allComponents: ComponentData[];
}
function ActionDetailBody(p: ActionDetailBodyProps) {
const action = p.config.action || {};
const messageRows = (
<>
<CPRow label="성공 메시지">
<CPText
value={action.successMessage || ""}
onChange={(v) => p.updateActionConfig("successMessage", v)}
placeholder="처리되었습니다."
/>
</CPRow>
<CPRow label="에러 메시지">
<CPText
value={action.errorMessage || ""}
onChange={(v) => p.updateActionConfig("errorMessage", v)}
placeholder="처리 중 오류가 발생했습니다."
/>
</CPRow>
</>
);
// ── save / delete / quickInsert
if (p.actionType === "save" || p.actionType === "delete" || p.actionType === "quickInsert") {
return (
<>
{messageRows}
{p.actionType === "delete" && (
<CPRow label="삭제 확인 팝업" help="삭제 전 확인 다이얼로그">
<CPSwitch
value={action.confirmBeforeDelete !== false}
onChange={(v) => p.updateActionConfig("confirmBeforeDelete", v)}
/>
</CPRow>
)}
</>
);
}
// ── edit
if (p.actionType === "edit") {
const editMode = action.editMode || "modal";
return (
<>
<CPRow label="수정 폼 화면" help="편집 시 열릴 화면 선택">
{p.screensLoading ? (
<InlineLoader text="화면 로딩..." />
) : (
<CPSelect
value={action.targetScreenId ? String(action.targetScreenId) : ""}
onChange={(v) => p.updateActionConfig("targetScreenId", v ? Number(v) : undefined)}
>
<option value=""> </option>
{p.screens.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.name}
</option>
))}
</CPSelect>
)}
</CPRow>
<CPRow label="편집 모드">
<CPSegment
value={editMode}
onChange={(v) => p.updateActionConfig("editMode", v)}
options={[
{ value: "modal", label: "모달" },
{ value: "navigate", label: "페이지 이동" },
]}
/>
</CPRow>
{editMode === "modal" && (
<>
<CPRow label="모달 제목">
<CPText
value={action.editModalTitle || ""}
onChange={(v) => p.updateActionConfig("editModalTitle", v)}
placeholder="데이터 수정"
/>
</CPRow>
<CPRow label="모달 설명">
<CPText
value={action.editModalDescription || ""}
onChange={(v) => p.updateActionConfig("editModalDescription", v)}
placeholder="모달 설명"
/>
</CPRow>
<CPRow label="모달 크기">
<CPSegment
value={action.modalSize || "lg"}
onChange={(v) => p.updateActionConfig("modalSize", v)}
options={MODAL_SIZE_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
/>
</CPRow>
</>
)}
{messageRows}
</>
);
}
// ── modal
if (p.actionType === "modal") {
return (
<>
<CPRow label="대상 화면">
{p.screensLoading ? (
<InlineLoader text="화면 로딩..." />
) : (
<CPSelect
value={action.targetScreenId ? String(action.targetScreenId) : ""}
onChange={(v) => p.updateActionConfig("targetScreenId", v ? Number(v) : undefined)}
>
<option value=""> </option>
{p.screens.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.name}
</option>
))}
</CPSelect>
)}
</CPRow>
<CPRow label="모달 제목">
<CPText
value={action.modalTitle || ""}
onChange={(v) => p.updateActionConfig("modalTitle", v)}
placeholder="모달 제목"
/>
</CPRow>
<CPRow label="모달 설명">
<CPText
value={action.modalDescription || ""}
onChange={(v) => p.updateActionConfig("modalDescription", v)}
placeholder="모달 설명"
/>
</CPRow>
<CPRow label="데이터 자동 전달" help="선택된 행을 모달에 전달">
<CPSwitch
value={action.autoDetectDataSource || false}
onChange={(v) => p.updateActionConfig("autoDetectDataSource", v)}
/>
</CPRow>
{messageRows}
</>
);
}
// ── navigate
if (p.actionType === "navigate") {
return (
<>
<CPRow label="이동 대상 URL">
<CPText
value={action.targetUrl || ""}
onChange={(v) => p.updateActionConfig("targetUrl", v)}
placeholder="/admin/example"
/>
</CPRow>
{messageRows}
</>
);
}
// ── excel_download
if (p.actionType === "excel_download") {
return (
<>
<CPRow label="현재 필터 적용" help="검색 조건이 적용된 데이터만 다운">
<CPSwitch
value={action.applyCurrentFilters !== false}
onChange={(v) => p.updateActionConfig("applyCurrentFilters", v)}
/>
</CPRow>
<CPRow label="선택된 행만" help="테이블에서 선택한 행만">
<CPSwitch
value={action.selectedRowsOnly || false}
onChange={(v) => p.updateActionConfig("selectedRowsOnly", v)}
/>
</CPRow>
{messageRows}
</>
);
}
// ── excel_upload / multi_table_excel_upload
if (p.actionType === "excel_upload" || p.actionType === "multi_table_excel_upload") {
return <>{messageRows}</>;
}
// ── transferData
if (p.actionType === "transferData") {
const dt = action.dataTransfer || {};
const updateDt = (k: string, v: any) =>
p.updateActionConfig("dataTransfer", { ...dt, [k]: v });
return (
<>
<CPRow label="소스 컴포넌트" help="데이터를 가져올 컴포넌트">
<CPSelect
value={dt.sourceComponentId || ""}
onChange={(v) => updateDt("sourceComponentId", v)}
>
<option value="">...</option>
<option value="__auto__"> ( )</option>
{p.allComponents
.filter((c: any) => {
const t = c.componentType || c.type || "";
return isDataTransferComponentType(t);
})
.map((c: any) => (
<option key={c.id} value={c.id}>
{c.label || c.componentConfig?.title || c.id}
</option>
))}
</CPSelect>
</CPRow>
<CPRow label="타겟 타입">
<CPSegment
value={dt.targetType || "component"}
onChange={(v) => updateDt("targetType", v)}
options={[
{ value: "component", label: "컴포넌트" },
{ value: "splitPanel", label: "분할 반대편" },
]}
/>
</CPRow>
{dt.targetType === "component" && (
<CPRow label="타겟 컴포넌트">
<CPSelect
value={dt.targetComponentId || ""}
onChange={(v) => updateDt("targetComponentId", v)}
>
<option value="">...</option>
{p.allComponents
.filter((c: any) => {
const t = c.componentType || c.type || "";
const ok = isDataTransferComponentType(t);
return ok && c.id !== dt.sourceComponentId;
})
.map((c: any) => (
<option key={c.id} value={c.id}>
{c.label || c.componentConfig?.title || c.id}
</option>
))}
</CPSelect>
</CPRow>
)}
<CPRow label="전달 모드">
<CPSegment
value={dt.mode || "append"}
onChange={(v) => updateDt("mode", v)}
options={TRANSFER_MODE_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
/>
</CPRow>
<CPRow label="전달 후 소스 초기화" help="전달 후 선택 해제">
<CPSwitch
value={dt.clearAfterTransfer || false}
onChange={(v) => updateDt("clearAfterTransfer", v)}
/>
</CPRow>
<CPRow label="전달 전 확인" help="확인 다이얼로그 표시">
<CPSwitch
value={dt.confirmBeforeTransfer || false}
onChange={(v) => updateDt("confirmBeforeTransfer", v)}
/>
</CPRow>
{dt.confirmBeforeTransfer && (
<CPRow label="확인 메시지">
<CPText
value={dt.confirmMessage || ""}
onChange={(v) => updateDt("confirmMessage", v)}
placeholder="선택한 항목을 전달하시겠습니까?"
/>
</CPRow>
)}
<Hint> "데이터 전달 매핑" </Hint>
{messageRows}
</>
);
}
// ── event
if (p.actionType === "event") {
return (
<>
<CPRow label="이벤트명">
<CPText
value={action.eventName || ""}
onChange={(v) => p.updateActionConfig("eventName", v)}
placeholder="이벤트 이름"
/>
</CPRow>
{messageRows}
</>
);
}
// ── default (approval / control / copy / barcode_scan / operation_control)
return (
<>
<Hint> ( ).</Hint>
{messageRows}
</>
);
}
// ───────────────────────────────────────────────────────
// IconOptionsBody — 아이콘 설정 (추천 / 커스텀 / 검색 / SVG / 크기 / 위치 / 간격)
// ───────────────────────────────────────────────────────
interface IconOptionsBodyProps {
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 }>;
lucideSearchTerm: string;
setLucideSearchTerm: (v: string) => void;
svgInput: string;
setSvgInput: (v: string) => void;
svgName: string;
setSvgName: (v: string) => void;
svgError: string;
setSvgError: (v: 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;
}
function IconOptionsBody(p: IconOptionsBodyProps) {
const [showLucideSearch, setShowLucideSearch] = useState(false);
const [showSvgPaste, setShowSvgPaste] = useState(false);
return (
<>
{/* 추천 아이콘 */}
{p.isNoIconAction ? (
<Hint>{NO_ICON_MESSAGE}</Hint>
) : (
<>
<SectionLabel text="추천 아이콘" />
<IconGrid
icons={p.currentActionIcons}
selectedIcon={p.selectedIcon}
selectedType={p.selectedIconType}
type="lucide"
onSelect={p.onSelectIcon}
/>
</>
)}
{/* 커스텀 아이콘 */}
{(p.customIcons.length > 0 || p.customSvgIcons.length > 0) && (
<div style={{ marginTop: 10 }}>
<SectionLabel text="커스텀 아이콘" primary />
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: 4,
}}
>
{p.customIcons.map((iconName) => (
<CustomIconCard
key={`c-${iconName}`}
iconName={iconName}
type="lucide"
isSelected={p.selectedIcon === iconName && p.selectedIconType === "lucide"}
onSelect={() => p.onSelectIcon(iconName, "lucide")}
onRemove={() => {
p.onCustomIconsChange(p.customIcons.filter((n) => n !== iconName));
if (p.selectedIcon === iconName) p.onRevertToDefault();
}}
/>
))}
{p.customSvgIcons.map((s) => (
<CustomIconCard
key={`s-${s.name}`}
iconName={s.name}
type="svg"
svg={s.svg}
isSelected={p.selectedIcon === s.name && p.selectedIconType === "svg"}
onSelect={() => p.onSelectIcon(s.name, "svg")}
onRemove={() => {
p.onCustomSvgIconsChange(p.customSvgIcons.filter((x) => x.name !== s.name));
if (p.selectedIcon === s.name) p.onRevertToDefault();
}}
/>
))}
</div>
</div>
)}
{/* 추가 버튼 */}
<div style={{ marginTop: 8, display: "flex", gap: 6 }}>
<button
type="button"
onClick={() => setShowLucideSearch((x) => !x)}
style={addBtnStyle(showLucideSearch)}
>
<Plus size={11} /> Lucide
</button>
<button
type="button"
onClick={() => setShowSvgPaste((x) => !x)}
style={addBtnStyle(showSvgPaste)}
>
<Plus size={11} /> SVG
</button>
</div>
{/* Lucide 검색 (인라인) */}
{showLucideSearch && (
<div style={inlinePanelStyle}>
<input
type="text"
value={p.lucideSearchTerm}
onChange={(e) => p.setLucideSearchTerm(e.target.value)}
placeholder="아이콘 이름 검색..."
style={searchInputStyle}
autoFocus
/>
<div
style={{
maxHeight: 180,
overflowY: "auto",
marginTop: 5,
}}
>
<IconGrid
icons={Object.keys(allLucideIcons)
.filter((n) => n.toLowerCase().includes(p.lucideSearchTerm.toLowerCase()))
.slice(0, 32)}
selectedIcon={p.selectedIcon}
selectedType={p.selectedIconType}
type="lucide"
onSelect={(name) => {
if (!p.customIcons.includes(name)) {
p.onCustomIconsChange([...p.customIcons, name]);
}
p.onSelectIcon(name, "lucide");
}}
/>
</div>
</div>
)}
{/* SVG 붙여넣기 (인라인) */}
{showSvgPaste && (
<div style={inlinePanelStyle}>
<CPRow label="이름">
<CPText value={p.svgName} onChange={p.setSvgName} placeholder="아이콘 이름" />
</CPRow>
<textarea
value={p.svgInput}
onChange={(e) => {
p.setSvgInput(e.target.value);
if (p.svgError) p.setSvgError("");
}}
onKeyDown={(e) => e.stopPropagation()}
placeholder='<svg xmlns="http://www.w3.org/2000/svg" ...>...</svg>'
style={{
width: "100%",
minHeight: 70,
padding: 6,
fontSize: 10.5,
fontFamily: "var(--v5-font-mono)",
background: "var(--cp-surface)",
border: "1px solid var(--cp-border)",
borderRadius: 4,
color: "var(--cp-text)",
marginTop: 5,
resize: "vertical",
}}
/>
{p.svgInput && (
<div
style={{
marginTop: 5,
padding: 6,
background: "var(--cp-bg-subtle)",
borderRadius: 4,
display: "flex",
justifyContent: "center",
}}
>
<span
style={{
width: 28,
height: 28,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
color: "var(--cp-text)",
}}
dangerouslySetInnerHTML={{ __html: sanitizeSvg(p.svgInput) }}
/>
</div>
)}
{p.svgError && <Hint tone="warn">{p.svgError}</Hint>}
<button
type="button"
onClick={() => {
if (!p.svgName.trim()) {
p.setSvgError("아이콘 이름을 입력하세요.");
return;
}
if (!p.svgInput.trim().includes("<svg")) {
p.setSvgError("유효한 SVG 코드가 아닙니다.");
return;
}
const sanitized = sanitizeSvg(p.svgInput);
let finalName = p.svgName.trim();
const existing = new Set(p.customSvgIcons.map((s) => s.name));
if (existing.has(finalName)) {
let n = 2;
while (existing.has(`${p.svgName.trim()}(${n})`)) n++;
finalName = `${p.svgName.trim()}(${n})`;
}
p.onCustomSvgIconsChange([
...p.customSvgIcons,
{ name: finalName, svg: sanitized },
]);
p.setSvgInput("");
p.setSvgName("");
p.setSvgError("");
setShowSvgPaste(false);
}}
style={{
marginTop: 6,
width: "100%",
padding: "6px 8px",
fontSize: 11,
fontWeight: 600,
background: "var(--v5-primary, #6c5ce7)",
color: "#fff",
border: "none",
borderRadius: 4,
cursor: "pointer",
fontFamily: "var(--v5-font-sans)",
}}
>
</button>
</div>
)}
{/* 아이콘 크기 */}
<div style={{ marginTop: 10 }}>
<CPRow label="아이콘 크기">
<CPSegment
value={p.iconSize}
onChange={p.onIconSizeChange}
options={Object.keys(iconSizePresets).map((v) => ({ value: v, label: v }))}
/>
</CPRow>
</div>
{/* 텍스트 위치 (icon-text 일 때) */}
{p.displayMode === "icon-text" && (
<>
<CPRow label="텍스트 위치">
<CPSegment
value={p.iconTextPosition}
onChange={p.onIconTextPositionChange}
options={ICON_TEXT_POSITION_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
/>
</CPRow>
<CPRow label="아이콘-텍스트 간격">
<CPNumber
value={p.iconGap}
onChange={(v) => p.onIconGapChange(v ?? 6)}
min={0}
max={32}
suffix="px"
/>
</CPRow>
</>
)}
</>
);
}
// ───────────────────────────────────────────────────────
// IconGrid — 추천 / 검색 / 커스텀 공통
// ───────────────────────────────────────────────────────
function IconGrid({
icons,
selectedIcon,
selectedType,
type,
onSelect,
}: {
icons: string[];
selectedIcon: string;
selectedType: "lucide" | "svg";
type: "lucide" | "svg";
onSelect: (name: string, type?: "lucide" | "svg") => void;
}) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: 4,
}}
>
{icons.map((iconName) => {
const Icon = getLucideIcon(iconName);
if (!Icon) return null;
const active = selectedIcon === iconName && selectedType === type;
return (
<button
key={iconName}
type="button"
onClick={() => onSelect(iconName, type)}
title={iconName}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 3,
padding: "6px 4px",
background: active
? "rgba(var(--v5-primary-rgb), 0.10)"
: "var(--cp-bg-subtle)",
border: `1px solid ${
active ? "rgba(var(--v5-primary-rgb), 0.5)" : "var(--cp-border-subtle)"
}`,
borderRadius: 4,
cursor: "pointer",
fontFamily: "var(--v5-font-sans)",
color: active ? "var(--v5-primary, #6c5ce7)" : "var(--cp-text-sec)",
minHeight: 42,
}}
>
<Icon size={16} />
<span
style={{
fontSize: 8.5,
color: "var(--cp-text-muted)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "100%",
}}
>
{iconName}
</span>
</button>
);
})}
</div>
);
}
function CustomIconCard({
iconName,
type,
svg,
isSelected,
onSelect,
onRemove,
}: {
iconName: string;
type: "lucide" | "svg";
svg?: string;
isSelected: boolean;
onSelect: () => void;
onRemove: () => void;
}) {
const Icon = type === "lucide" ? getLucideIcon(iconName) : null;
return (
<div style={{ position: "relative" }}>
<button
type="button"
onClick={onSelect}
title={iconName}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 3,
padding: "6px 4px",
background: isSelected
? "rgba(var(--v5-primary-rgb), 0.10)"
: "var(--cp-bg-subtle)",
border: `1px solid ${
isSelected ? "rgba(var(--v5-primary-rgb), 0.5)" : "var(--cp-border-subtle)"
}`,
borderRadius: 4,
cursor: "pointer",
width: "100%",
color: isSelected ? "var(--v5-primary, #6c5ce7)" : "var(--cp-text-sec)",
minHeight: 42,
}}
>
{type === "lucide" && Icon ? (
<Icon size={16} />
) : svg ? (
<span
style={{ width: 16, height: 16, display: "inline-flex" }}
dangerouslySetInnerHTML={{ __html: sanitizeSvg(svg) }}
/>
) : null}
<span
style={{
fontSize: 8.5,
color: "var(--cp-text-muted)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "100%",
}}
>
{iconName}
</span>
</button>
<button
type="button"
onClick={onRemove}
style={{
position: "absolute",
top: -4,
right: -4,
width: 14,
height: 14,
borderRadius: 999,
background: "var(--v5-red, #ef4444)",
color: "#fff",
border: "none",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 9,
}}
>
<X size={9} />
</button>
</div>
);
}
// 인라인 추가 버튼 / 패널 스타일
function addBtnStyle(active: boolean): React.CSSProperties {
return {
flex: 1,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 4,
padding: "5px 8px",
fontSize: 10.5,
background: active ? "rgba(var(--v5-primary-rgb), 0.10)" : "var(--cp-bg-subtle)",
border: `1px solid ${
active ? "rgba(var(--v5-primary-rgb), 0.45)" : "var(--cp-border-subtle)"
}`,
borderRadius: 4,
cursor: "pointer",
color: active ? "var(--v5-primary, #6c5ce7)" : "var(--cp-text-sec)",
fontFamily: "var(--v5-font-sans)",
};
}
const inlinePanelStyle: React.CSSProperties = {
marginTop: 6,
padding: 8,
background: "var(--cp-bg-subtle)",
border: "1px solid var(--cp-border-subtle)",
borderRadius: 5,
};
const searchInputStyle: React.CSSProperties = {
width: "100%",
height: 26,
padding: "0 8px",
fontSize: 11.5,
background: "var(--cp-surface)",
border: "1px solid var(--cp-border)",
borderRadius: 4,
color: "var(--cp-text)",
outline: "none",
fontFamily: "var(--v5-font-sans)",
};
// ───────────────────────────────────────────────────────
// TransferDataMappingBody — 데이터 전달 매핑 (multi table)
// ───────────────────────────────────────────────────────
interface TransferDataMappingBodyProps {
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 }>;
activeMappingGroupIndex: number;
setActiveMappingGroupIndex: (i: number) => void;
loadTableColumns: (tableName: string) => Promise<Array<{ name: string; label: string }>>;
}
function TransferDataMappingBody(p: TransferDataMappingBodyProps) {
const dt = p.config.action?.dataTransfer || {};
const multi: Array<{
sourceTable: string;
mappingRules: Array<{ sourceField: string; targetField: string }>;
}> = dt.multiTableMappings || [];
const updateDt = (field: string, value: any) => {
const a = p.config.action || {};
const cur = a.dataTransfer || {};
p.onChange({ ...p.config, action: { ...a, dataTransfer: { ...cur, [field]: value } } });
};
const activeGroup = multi[p.activeMappingGroupIndex];
const activeSourceTable = activeGroup?.sourceTable || "";
const activeSourceColumns = p.mappingSourceColumnsMap[activeSourceTable] || [];
const activeRules = activeGroup?.mappingRules || [];
const updateGroupField = (field: string, value: any) => {
const arr = [...multi];
arr[p.activeMappingGroupIndex] = { ...arr[p.activeMappingGroupIndex], [field]: value };
updateDt("multiTableMappings", arr);
};
return (
<>
<SectionLabel icon={<SendHorizontal size={11} />} text="데이터 전달 매핑" primary />
<Hint> </Hint>
<CPRow label="타겟 테이블">
<CPSelect
value={dt.targetTable || ""}
onChange={(v) => updateDt("targetTable", v)}
>
<option value="">...</option>
{p.availableTables.map((t) => (
<option key={t.name} value={t.name}>
{t.label}
</option>
))}
</CPSelect>
</CPRow>
{!dt.targetTable ? (
<Hint tone="warn"> </Hint>
) : (
<>
{/* 소스 테이블 그룹 탭 */}
<div style={{ marginTop: 8, display: "flex", flexWrap: "wrap", gap: 4 }}>
{multi.map((g, i) => {
const active = i === p.activeMappingGroupIndex;
return (
<div key={i} style={{ display: "flex", alignItems: "center" }}>
<button
type="button"
onClick={() => p.setActiveMappingGroupIndex(i)}
style={{
padding: "4px 8px",
fontSize: 10.5,
background: active
? "var(--v5-primary, #6c5ce7)"
: "var(--cp-bg-subtle)",
color: active ? "#fff" : "var(--cp-text-sec)",
border: `1px solid ${
active ? "var(--v5-primary, #6c5ce7)" : "var(--cp-border-subtle)"
}`,
borderRight: "none",
borderRadius: "4px 0 0 4px",
cursor: "pointer",
fontFamily: "var(--v5-font-sans)",
}}
>
{g.sourceTable
? p.availableTables.find((t) => t.name === g.sourceTable)?.label ||
g.sourceTable
: `그룹 ${i + 1}`}
{g.mappingRules?.length > 0 && (
<span
style={{
marginLeft: 4,
padding: "0 5px",
background: active
? "rgba(255,255,255,0.25)"
: "rgba(var(--v5-primary-rgb), 0.15)",
borderRadius: 999,
fontSize: 9,
fontWeight: 700,
}}
>
{g.mappingRules.length}
</span>
)}
</button>
<button
type="button"
onClick={() => {
const arr = [...multi];
arr.splice(i, 1);
updateDt("multiTableMappings", arr);
if (p.activeMappingGroupIndex >= arr.length) {
p.setActiveMappingGroupIndex(Math.max(0, arr.length - 1));
}
}}
style={{
padding: "4px 5px",
background: active
? "var(--v5-primary, #6c5ce7)"
: "var(--cp-bg-subtle)",
color: active ? "#fff" : "var(--v5-red, #ef4444)",
border: `1px solid ${
active ? "var(--v5-primary, #6c5ce7)" : "var(--cp-border-subtle)"
}`,
borderRadius: "0 4px 4px 0",
cursor: "pointer",
display: "flex",
alignItems: "center",
}}
>
<X size={10} />
</button>
</div>
);
})}
<button
type="button"
onClick={() => {
updateDt("multiTableMappings", [
...multi,
{ sourceTable: "", mappingRules: [] },
]);
p.setActiveMappingGroupIndex(multi.length);
}}
style={{
padding: "4px 8px",
fontSize: 10.5,
background: "var(--cp-bg-subtle)",
color: "var(--cp-text-sec)",
border: "1px dashed var(--cp-border)",
borderRadius: 4,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 3,
fontFamily: "var(--v5-font-sans)",
}}
>
<Plus size={10} />
</button>
</div>
{/* 활성 그룹 편집 */}
{activeGroup && (
<div
style={{
marginTop: 8,
padding: 8,
background: "var(--cp-bg-subtle)",
border: "1px solid var(--cp-border-subtle)",
borderRadius: 4,
}}
>
<CPRow label="소스 테이블">
<CPSelect
value={activeSourceTable}
onChange={async (v) => {
updateGroupField("sourceTable", v);
if (v && !p.mappingSourceColumnsMap[v]) {
const cols = await p.loadTableColumns(v);
p.setMappingSourceColumnsMap((prev) => ({ ...prev, [v]: cols }));
}
}}
>
<option value="">...</option>
{p.availableTables.map((t) => (
<option key={t.name} value={t.name}>
{t.label}
</option>
))}
</CPSelect>
</CPRow>
<div style={{ marginTop: 6 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 4,
}}
>
<SectionLabel text={`매핑 규칙 ${activeRules.length}`} />
<button
type="button"
onClick={() =>
updateGroupField("mappingRules", [
...activeRules,
{ sourceField: "", targetField: "" },
])
}
disabled={!activeSourceTable}
style={{
padding: "3px 7px",
fontSize: 10,
background: "var(--cp-bg-subtle)",
border: "1px solid var(--cp-border)",
borderRadius: 4,
cursor: activeSourceTable ? "pointer" : "not-allowed",
color: "var(--cp-text)",
opacity: activeSourceTable ? 1 : 0.5,
display: "inline-flex",
alignItems: "center",
gap: 3,
fontFamily: "var(--v5-font-sans)",
}}
>
<Plus size={10} />
</button>
</div>
{!activeSourceTable ? (
<Hint tone="warn"> </Hint>
) : activeRules.length === 0 ? (
<Hint> ( )</Hint>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{activeRules.map((rule, ri) => (
<div
key={ri}
style={{
display: "grid",
gridTemplateColumns: "1fr 14px 1fr 24px",
gap: 4,
alignItems: "center",
}}
>
<CPSelect
value={rule.sourceField}
onChange={(v) => {
const arr = [...activeRules];
arr[ri] = { ...arr[ri], sourceField: v };
updateGroupField("mappingRules", arr);
}}
>
<option value=""></option>
{activeSourceColumns.map((c) => (
<option key={c.name} value={c.name}>
{c.label}
</option>
))}
</CPSelect>
<ArrowRight
size={10}
style={{ color: "var(--cp-text-muted)", margin: "0 auto" }}
/>
<CPSelect
value={rule.targetField}
onChange={(v) => {
const arr = [...activeRules];
arr[ri] = { ...arr[ri], targetField: v };
updateGroupField("mappingRules", arr);
}}
>
<option value=""></option>
{p.mappingTargetColumns.map((c) => (
<option key={c.name} value={c.name}>
{c.label}
</option>
))}
</CPSelect>
<CPIconBtn
tone="danger"
size={20}
onClick={() => {
const arr = [...activeRules];
arr.splice(ri, 1);
updateGroupField("mappingRules", arr);
}}
>
<X size={10} />
</CPIconBtn>
</div>
))}
</div>
)}
</div>
</div>
)}
</>
)}
</>
);
}
export default InvLegacyButtonConfigPanel;