1723 lines
60 KiB
TypeScript
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;
|