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

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;