1403 lines
50 KiB
TypeScript
1403 lines
50 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* V2Button 설정 패널
|
|
* 토스식 단계별 UX: 액션 유형 카드 선택 -> 표시 모드 카드 -> 액션별 세부 설정 -> 고급 설정(접힘)
|
|
*/
|
|
|
|
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from "@/components/ui/collapsible";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import {
|
|
Save,
|
|
Trash2,
|
|
Pencil,
|
|
ArrowRight,
|
|
Maximize2,
|
|
SendHorizontal,
|
|
Download,
|
|
Upload,
|
|
Zap,
|
|
Settings,
|
|
ChevronDown,
|
|
Check,
|
|
Plus,
|
|
X,
|
|
Type,
|
|
Image,
|
|
Columns,
|
|
ScanLine,
|
|
Truck,
|
|
Send,
|
|
Copy,
|
|
FileSpreadsheet,
|
|
ChevronsUpDown,
|
|
Info,
|
|
Workflow,
|
|
} from "lucide-react";
|
|
import { icons as allLucideIcons } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import {
|
|
actionIconMap,
|
|
noIconActions,
|
|
NO_ICON_MESSAGE,
|
|
iconSizePresets,
|
|
getLucideIcon,
|
|
addToIconMap,
|
|
getDefaultIconForAction,
|
|
sanitizeSvg,
|
|
} from "@/lib/button-icon-map";
|
|
import { ButtonDataflowConfigPanel } from "@/components/screen/config-panels/ButtonDataflowConfigPanel";
|
|
import { ImprovedButtonControlConfigPanel } from "@/components/screen/config-panels/ImprovedButtonControlConfigPanel";
|
|
import { FlowVisibilityConfigPanel } from "@/components/screen/config-panels/FlowVisibilityConfigPanel";
|
|
import type { ComponentData } from "@/types/screen";
|
|
|
|
// ─── 액션 유형 카드 정의 ───
|
|
const ACTION_TYPE_CARDS = [
|
|
{
|
|
value: "save",
|
|
icon: Save,
|
|
title: "저장",
|
|
description: "데이터를 저장해요",
|
|
},
|
|
{
|
|
value: "delete",
|
|
icon: Trash2,
|
|
title: "삭제",
|
|
description: "데이터를 삭제해요",
|
|
},
|
|
{
|
|
value: "edit",
|
|
icon: Pencil,
|
|
title: "편집",
|
|
description: "데이터를 수정해요",
|
|
},
|
|
{
|
|
value: "modal",
|
|
icon: Maximize2,
|
|
title: "모달 열기",
|
|
description: "팝업 화면을 열어요",
|
|
},
|
|
{
|
|
value: "navigate",
|
|
icon: ArrowRight,
|
|
title: "페이지 이동",
|
|
description: "다른 화면으로 이동해요",
|
|
},
|
|
{
|
|
value: "transferData",
|
|
icon: SendHorizontal,
|
|
title: "데이터 전달",
|
|
description: "다른 테이블로 전달해요",
|
|
},
|
|
{
|
|
value: "excel_download",
|
|
icon: Download,
|
|
title: "엑셀 다운로드",
|
|
description: "데이터를 엑셀로 받아요",
|
|
},
|
|
{
|
|
value: "excel_upload",
|
|
icon: Upload,
|
|
title: "엑셀 업로드",
|
|
description: "엑셀 파일을 올려요",
|
|
},
|
|
{
|
|
value: "quickInsert",
|
|
icon: Zap,
|
|
title: "즉시 저장",
|
|
description: "바로 저장해요",
|
|
},
|
|
{
|
|
value: "approval",
|
|
icon: Check,
|
|
title: "결재 요청",
|
|
description: "결재를 요청해요",
|
|
},
|
|
{
|
|
value: "control",
|
|
icon: Settings,
|
|
title: "제어 흐름",
|
|
description: "흐름을 제어해요",
|
|
},
|
|
{
|
|
value: "event",
|
|
icon: Send,
|
|
title: "이벤트 발송",
|
|
description: "이벤트를 보내요",
|
|
},
|
|
{
|
|
value: "copy",
|
|
icon: Copy,
|
|
title: "복사",
|
|
description: "데이터를 복사해요",
|
|
},
|
|
{
|
|
value: "barcode_scan",
|
|
icon: ScanLine,
|
|
title: "바코드 스캔",
|
|
description: "바코드를 스캔해요",
|
|
},
|
|
{
|
|
value: "operation_control",
|
|
icon: Truck,
|
|
title: "운행알림/종료",
|
|
description: "운행을 관리해요",
|
|
},
|
|
{
|
|
value: "multi_table_excel_upload",
|
|
icon: FileSpreadsheet,
|
|
title: "다중 엑셀 업로드",
|
|
description: "여러 테이블에 올려요",
|
|
},
|
|
] as const;
|
|
|
|
// ─── 표시 모드 카드 정의 ───
|
|
const DISPLAY_MODE_CARDS = [
|
|
{
|
|
value: "text" as const,
|
|
icon: Type,
|
|
title: "텍스트",
|
|
description: "텍스트만 표시",
|
|
},
|
|
{
|
|
value: "icon" as const,
|
|
icon: Image,
|
|
title: "아이콘",
|
|
description: "아이콘만 표시",
|
|
},
|
|
{
|
|
value: "icon-text" as const,
|
|
icon: Columns,
|
|
title: "아이콘+텍스트",
|
|
description: "둘 다 표시",
|
|
},
|
|
] as const;
|
|
|
|
// ─── 버튼 변형 옵션 ───
|
|
const VARIANT_OPTIONS = [
|
|
{ value: "primary", label: "기본 (Primary)" },
|
|
{ value: "secondary", label: "보조 (Secondary)" },
|
|
{ value: "danger", label: "위험 (Danger)" },
|
|
] as const;
|
|
|
|
interface ScreenOption {
|
|
id: number;
|
|
name: string;
|
|
description?: string;
|
|
}
|
|
|
|
interface V2ButtonConfigPanelProps {
|
|
config: Record<string, any>;
|
|
onChange: (config: Record<string, any>) => void;
|
|
component?: ComponentData;
|
|
currentComponent?: ComponentData;
|
|
onUpdateProperty?: (path: string, value: any) => void;
|
|
allComponents?: ComponentData[];
|
|
currentTableName?: string;
|
|
screenTableName?: string;
|
|
currentScreenCompanyCode?: string;
|
|
[key: string]: any;
|
|
}
|
|
|
|
export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
|
|
config,
|
|
onChange,
|
|
component,
|
|
currentComponent,
|
|
onUpdateProperty,
|
|
allComponents = [],
|
|
currentTableName,
|
|
screenTableName,
|
|
currentScreenCompanyCode,
|
|
}) => {
|
|
const effectiveComponent = component || currentComponent;
|
|
const effectiveTableName = currentTableName || screenTableName;
|
|
const actionType = String(config.action?.type || "save");
|
|
const displayMode = (config.displayMode as "text" | "icon" | "icon-text") || "text";
|
|
const variant = config.variant || "primary";
|
|
const buttonText = config.text !== undefined ? config.text : "버튼";
|
|
|
|
// 아이콘 상태
|
|
const [selectedIcon, setSelectedIcon] = useState<string>(config.icon?.name || "");
|
|
const [selectedIconType, setSelectedIconType] = useState<"lucide" | "svg">(
|
|
config.icon?.type || "lucide"
|
|
);
|
|
const [iconSize, setIconSize] = useState<string>(config.icon?.size || "보통");
|
|
|
|
// UI 상태
|
|
const [iconSectionOpen, setIconSectionOpen] = useState(false);
|
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
|
const [dataflowOpen, setDataflowOpen] = 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 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]
|
|
);
|
|
|
|
// onUpdateProperty 래퍼 (V2 패널에서도 기존 컨트롤 패널 사용 가능하도록)
|
|
const handleUpdateProperty = useCallback(
|
|
(path: string, value: any) => {
|
|
if (onUpdateProperty) {
|
|
onUpdateProperty(path, value);
|
|
} else {
|
|
// path를 파싱해서 config에 직접 반영
|
|
const parts = path.replace("componentConfig.", "").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, onUpdateProperty]
|
|
);
|
|
|
|
// 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 !== "modal" && actionType !== "navigate") return;
|
|
if (screens.length > 0) return;
|
|
|
|
const loadScreens = async () => {
|
|
setScreensLoading(true);
|
|
try {
|
|
const response = await apiClient.get("/screen-management/screens");
|
|
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 생성 (기존 패널 재사용용)
|
|
const componentData = useMemo(() => {
|
|
if (effectiveComponent) return effectiveComponent;
|
|
return {
|
|
id: "virtual",
|
|
type: "widget" as const,
|
|
position: { x: 0, y: 0 },
|
|
size: { width: 120, height: 40 },
|
|
componentConfig: 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}
|
|
/>
|
|
|
|
{/* ─── 아이콘 설정 (접기) ─── */}
|
|
{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={dataflowOpen} onOpenChange={setDataflowOpen}>
|
|
<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">
|
|
<Workflow 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",
|
|
dataflowOpen && "rotate-180"
|
|
)}
|
|
/>
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="rounded-b-lg border border-t-0 p-4">
|
|
<ButtonDataflowConfigPanel
|
|
component={componentData}
|
|
onUpdateProperty={handleUpdateProperty}
|
|
/>
|
|
</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>
|
|
|
|
{/* 제어 기능 */}
|
|
{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;
|
|
}> = ({
|
|
actionType,
|
|
config,
|
|
updateConfig,
|
|
updateActionConfig,
|
|
screens,
|
|
screensLoading,
|
|
modalScreenOpen,
|
|
setModalScreenOpen,
|
|
modalSearchTerm,
|
|
setModalSearchTerm,
|
|
currentTableName,
|
|
}) => {
|
|
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>
|
|
<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) =>
|
|
s.name.toLowerCase().includes(modalSearchTerm.toLowerCase()) ||
|
|
s.description?.toLowerCase().includes(modalSearchTerm.toLowerCase())
|
|
)
|
|
.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 "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>
|
|
);
|
|
};
|
|
|
|
V2ButtonConfigPanel.displayName = "V2ButtonConfigPanel";
|
|
|
|
export default V2ButtonConfigPanel;
|