ERP-node/frontend/lib/registry/pop-components/pop-button.tsx

1048 lines
30 KiB
TypeScript

"use client";
import { useState, useCallback } from "react";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { useDataSource } from "@/hooks/pop/useDataSource";
import * as LucideIcons from "lucide-react";
import { toast } from "sonner";
// ========================================
// STEP 1: 타입 정의
// ========================================
/** 메인 액션 타입 (5종) */
export type ButtonActionType = "save" | "delete" | "api" | "modal" | "event";
/** 후속 액션 타입 (3종) */
export type FollowUpActionType = "event" | "refresh" | "navigate";
/** 버튼 variant (shadcn 기반 4종) */
export type ButtonVariant = "default" | "secondary" | "outline" | "destructive";
/** 모달 열기 방식 */
export type ModalMode = "dropdown" | "fullscreen" | "screen-ref";
/** 확인 다이얼로그 설정 */
export interface ConfirmConfig {
enabled: boolean;
message?: string; // 빈값이면 기본 메시지
}
/** 후속 액션 1건 */
export interface FollowUpAction {
type: FollowUpActionType;
// event
eventName?: string;
eventPayload?: Record<string, unknown>;
// navigate
targetScreenId?: string;
params?: Record<string, string>;
}
/** 드롭다운 모달 메뉴 항목 */
export interface ModalMenuItem {
label: string;
screenId?: string;
action?: string; // 커스텀 이벤트명
}
/** 메인 액션 설정 */
export interface ButtonMainAction {
type: ButtonActionType;
// save/delete 공통
targetTable?: string;
// api
apiEndpoint?: string;
apiMethod?: "GET" | "POST" | "PUT" | "DELETE";
// modal
modalMode?: ModalMode;
modalScreenId?: string;
modalTitle?: string;
modalItems?: ModalMenuItem[];
// event
eventName?: string;
eventPayload?: Record<string, unknown>;
}
/** 프리셋 이름 */
export type ButtonPreset =
| "save"
| "delete"
| "logout"
| "menu"
| "modal-open"
| "custom";
/** pop-button 전체 설정 */
export interface PopButtonConfig {
label: string;
variant: ButtonVariant;
icon?: string; // Lucide 아이콘 이름
iconOnly?: boolean;
preset: ButtonPreset;
confirm?: ConfirmConfig;
action: ButtonMainAction;
followUpActions?: FollowUpAction[];
}
// ========================================
// 상수
// ========================================
/** 메인 액션 타입 라벨 */
const ACTION_TYPE_LABELS: Record<ButtonActionType, string> = {
save: "저장",
delete: "삭제",
api: "API 호출",
modal: "모달 열기",
event: "이벤트 발행",
};
/** 후속 액션 타입 라벨 */
const FOLLOWUP_TYPE_LABELS: Record<FollowUpActionType, string> = {
event: "이벤트 발행",
refresh: "새로고침",
navigate: "화면 이동",
};
/** variant 라벨 */
const VARIANT_LABELS: Record<ButtonVariant, string> = {
default: "기본 (Primary)",
secondary: "보조 (Secondary)",
outline: "외곽선 (Outline)",
destructive: "위험 (Destructive)",
};
/** 프리셋 라벨 */
const PRESET_LABELS: Record<ButtonPreset, string> = {
save: "저장",
delete: "삭제",
logout: "로그아웃",
menu: "메뉴 (드롭다운)",
"modal-open": "모달 열기",
custom: "직접 설정",
};
/** 모달 모드 라벨 */
const MODAL_MODE_LABELS: Record<ModalMode, string> = {
dropdown: "드롭다운",
fullscreen: "전체 모달",
"screen-ref": "화면 선택",
};
/** API 메서드 라벨 */
const API_METHOD_LABELS: Record<string, string> = {
GET: "GET",
POST: "POST",
PUT: "PUT",
DELETE: "DELETE",
};
/** 주요 Lucide 아이콘 목록 (설정 패널용) */
const ICON_OPTIONS: { value: string; label: string }[] = [
{ value: "none", label: "없음" },
{ value: "Save", label: "저장 (Save)" },
{ value: "Trash2", label: "삭제 (Trash)" },
{ value: "LogOut", label: "로그아웃 (LogOut)" },
{ value: "Menu", label: "메뉴 (Menu)" },
{ value: "ExternalLink", label: "외부링크 (ExternalLink)" },
{ value: "Plus", label: "추가 (Plus)" },
{ value: "Check", label: "확인 (Check)" },
{ value: "X", label: "취소 (X)" },
{ value: "Edit", label: "수정 (Edit)" },
{ value: "Search", label: "검색 (Search)" },
{ value: "RefreshCw", label: "새로고침 (RefreshCw)" },
{ value: "Download", label: "다운로드 (Download)" },
{ value: "Upload", label: "업로드 (Upload)" },
{ value: "Send", label: "전송 (Send)" },
{ value: "Copy", label: "복사 (Copy)" },
{ value: "Settings", label: "설정 (Settings)" },
{ value: "ChevronDown", label: "아래 화살표 (ChevronDown)" },
];
/** 프리셋별 기본 설정 */
const PRESET_DEFAULTS: Record<ButtonPreset, Partial<PopButtonConfig>> = {
save: {
label: "저장",
variant: "default",
icon: "Save",
confirm: { enabled: false },
action: { type: "save" },
},
delete: {
label: "삭제",
variant: "destructive",
icon: "Trash2",
confirm: { enabled: true, message: "" },
action: { type: "delete" },
},
logout: {
label: "로그아웃",
variant: "outline",
icon: "LogOut",
confirm: { enabled: true, message: "로그아웃 하시겠습니까?" },
action: {
type: "api",
apiEndpoint: "/api/auth/logout",
apiMethod: "POST",
},
},
menu: {
label: "메뉴",
variant: "secondary",
icon: "Menu",
confirm: { enabled: false },
action: { type: "modal", modalMode: "dropdown" },
},
"modal-open": {
label: "열기",
variant: "outline",
icon: "ExternalLink",
confirm: { enabled: false },
action: { type: "modal", modalMode: "fullscreen" },
},
custom: {
label: "버튼",
variant: "default",
icon: "none",
confirm: { enabled: false },
action: { type: "save" },
},
};
/** 확인 다이얼로그 기본 메시지 (액션별) */
const DEFAULT_CONFIRM_MESSAGES: Record<ButtonActionType, string> = {
save: "저장하시겠습니까?",
delete: "삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
api: "실행하시겠습니까?",
modal: "열기하시겠습니까?",
event: "실행하시겠습니까?",
};
// ========================================
// 헬퍼 함수
// ========================================
/** 섹션 구분선 */
function SectionDivider({ label }: { label: string }) {
return (
<div className="pt-3 pb-1">
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-gray-300" />
<span className="text-xs font-medium text-gray-500">{label}</span>
<div className="h-px flex-1 bg-gray-300" />
</div>
</div>
);
}
/** Lucide 아이콘 동적 렌더링 */
function DynamicLucideIcon({
name,
size = 16,
className,
}: {
name: string;
size?: number;
className?: string;
}) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const IconComponent = (LucideIcons as any)[name];
if (!IconComponent) return null;
return <IconComponent size={size} className={className} />;
}
// ========================================
// STEP 2: 메인 컴포넌트
// ========================================
interface PopButtonComponentProps {
config?: PopButtonConfig;
label?: string;
isDesignMode?: boolean;
}
export function PopButtonComponent({
config,
label,
isDesignMode,
}: PopButtonComponentProps) {
const [showConfirm, setShowConfirm] = useState(false);
// 이벤트 훅 (1차: screenId 빈 문자열 - 후속 통합에서 주입)
const { publish } = usePopEvent("");
// 데이터 훅 (save/delete용, tableName 없으면 자동 스킵)
const { save, remove, refetch } = useDataSource({
tableName: config?.action?.targetTable || "",
});
// 확인 메시지 결정
const getConfirmMessage = useCallback((): string => {
if (config?.confirm?.message) return config.confirm.message;
return DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"];
}, [config?.confirm?.message, config?.action?.type]);
// 메인 액션 실행
const executeMainAction = useCallback(async (): Promise<boolean> => {
const action = config?.action;
if (!action) return false;
try {
switch (action.type) {
case "save": {
// sharedData에서 데이터 수집 (후속 통합에서 실제 구현)
const record: Record<string, unknown> = {};
const result = await save(record);
if (!result.success) {
toast.error(result.error || "저장 실패");
return false;
}
toast.success("저장되었습니다");
return true;
}
case "delete": {
// sharedData에서 ID 수집 (후속 통합에서 실제 구현)
const result = await remove("");
if (!result.success) {
toast.error(result.error || "삭제 실패");
return false;
}
toast.success("삭제되었습니다");
return true;
}
case "api": {
// 1차: toast 알림만 (실제 API 호출은 후속)
toast.info(
`API 호출 예정: ${action.apiMethod || "POST"} ${action.apiEndpoint || "(미설정)"}`
);
return true;
}
case "modal": {
// 1차: toast 알림만 (실제 모달은 후속)
toast.info(
`모달 열기 예정: ${MODAL_MODE_LABELS[action.modalMode || "fullscreen"]}`
);
return true;
}
case "event": {
if (action.eventName) {
publish(action.eventName, action.eventPayload);
toast.success(`이벤트 발행: ${action.eventName}`);
}
return true;
}
default:
return false;
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "액션 실행 실패";
toast.error(message);
return false;
}
}, [config?.action, save, remove, publish]);
// 후속 액션 순차 실행
const executeFollowUpActions = useCallback(async () => {
const actions = config?.followUpActions;
if (!actions || actions.length === 0) return;
for (const fa of actions) {
try {
switch (fa.type) {
case "event":
if (fa.eventName) {
publish(fa.eventName, fa.eventPayload);
}
break;
case "refresh":
publish("__refresh__");
await refetch();
break;
case "navigate":
if (fa.targetScreenId) {
window.location.href = `/pop/screens/${fa.targetScreenId}`;
return; // navigate 후 중단
}
break;
}
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "후속 액션 실패";
toast.error(message);
// 개별 실패 시 다음 진행
}
}
}, [config?.followUpActions, publish, refetch]);
// 클릭 핸들러
const handleClick = useCallback(async () => {
// 디자인 모드: 실제 실행 안 함
if (isDesignMode) {
toast.info(
`[디자인 모드] ${ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션`
);
return;
}
// 확인 다이얼로그 필요 시
if (config?.confirm?.enabled) {
setShowConfirm(true);
return;
}
// 바로 실행
const success = await executeMainAction();
if (success) {
await executeFollowUpActions();
}
}, [
isDesignMode,
config?.confirm?.enabled,
config?.action?.type,
executeMainAction,
executeFollowUpActions,
]);
// 확인 후 실행
const handleConfirmExecute = useCallback(async () => {
setShowConfirm(false);
const success = await executeMainAction();
if (success) {
await executeFollowUpActions();
}
}, [executeMainAction, executeFollowUpActions]);
// 외형
const buttonLabel = config?.label || label || "버튼";
const variant = config?.variant || "default";
const iconName = config?.icon || "";
const isIconOnly = config?.iconOnly || false;
return (
<>
<div className="flex h-full w-full items-center justify-center">
<Button
variant={variant}
onClick={handleClick}
className={cn(
"transition-transform active:scale-95",
isIconOnly && "px-2"
)}
>
{iconName && (
<DynamicLucideIcon
name={iconName}
size={16}
className={isIconOnly ? "" : "mr-1.5"}
/>
)}
{!isIconOnly && <span>{buttonLabel}</span>}
</Button>
</div>
{/* 확인 다이얼로그 */}
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg">
</AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
{getConfirmMessage()}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmExecute}
className={cn(
"h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm",
config?.action?.type === "delete" &&
"bg-destructive text-destructive-foreground hover:bg-destructive/90"
)}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
// ========================================
// STEP 3: 설정 패널
// ========================================
interface PopButtonConfigPanelProps {
config: PopButtonConfig;
onUpdate: (config: PopButtonConfig) => void;
}
export function PopButtonConfigPanel({
config,
onUpdate,
}: PopButtonConfigPanelProps) {
const isCustom = config?.preset === "custom";
// 프리셋 변경 핸들러
const handlePresetChange = (preset: ButtonPreset) => {
const defaults = PRESET_DEFAULTS[preset];
onUpdate({
...config,
preset,
label: defaults.label || config.label,
variant: defaults.variant || config.variant,
icon: defaults.icon ?? config.icon,
confirm: defaults.confirm || config.confirm,
action: (defaults.action as ButtonMainAction) || config.action,
// 후속 액션은 프리셋 변경 시 유지
});
};
// 메인 액션 업데이트 헬퍼
const updateAction = (updates: Partial<ButtonMainAction>) => {
onUpdate({
...config,
action: { ...config.action, ...updates },
});
};
return (
<div className="space-y-2 overflow-y-auto pr-1 pb-32">
{/* 프리셋 선택 */}
<SectionDivider label="프리셋" />
<Select
value={config?.preset || "custom"}
onValueChange={(v) => handlePresetChange(v as ButtonPreset)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(PRESET_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{!isCustom && (
<p className="text-[10px] text-muted-foreground">
</p>
)}
{/* 외형 설정 */}
<SectionDivider label="외형" />
<div className="space-y-2">
{/* 라벨 */}
<div>
<Label className="text-xs"></Label>
<Input
value={config?.label || ""}
onChange={(e) => onUpdate({ ...config, label: e.target.value })}
placeholder="버튼 텍스트"
className="h-8 text-xs"
/>
</div>
{/* variant */}
<div>
<Label className="text-xs"></Label>
<Select
value={config?.variant || "default"}
onValueChange={(v) =>
onUpdate({ ...config, variant: v as ButtonVariant })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(VARIANT_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 아이콘 */}
<div>
<Label className="text-xs"></Label>
<Select
value={config?.icon || "none"}
onValueChange={(v) =>
onUpdate({ ...config, icon: v === "none" ? "" : v })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="없음" />
</SelectTrigger>
<SelectContent>
{ICON_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
<div className="flex items-center gap-2">
{opt.value && (
<DynamicLucideIcon name={opt.value} size={14} />
)}
<span>{opt.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 아이콘 전용 모드 */}
<div className="flex items-center gap-2">
<Checkbox
id="iconOnly"
checked={config?.iconOnly || false}
onCheckedChange={(checked) =>
onUpdate({ ...config, iconOnly: checked === true })
}
/>
<Label htmlFor="iconOnly" className="text-xs">
( )
</Label>
</div>
</div>
{/* 메인 액션 */}
<SectionDivider label="메인 액션" />
<div className="space-y-2">
{/* 액션 타입 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={config?.action?.type || "save"}
onValueChange={(v) =>
updateAction({ type: v as ButtonActionType })
}
disabled={!isCustom}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(ACTION_TYPE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{!isCustom && (
<p className="text-[10px] text-muted-foreground mt-1">
. &quot; &quot;
</p>
)}
</div>
{/* 액션별 추가 설정 */}
<ActionDetailFields
action={config?.action}
onUpdate={updateAction}
disabled={!isCustom}
/>
</div>
{/* 확인 다이얼로그 */}
<SectionDivider label="확인 메시지" />
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="confirmEnabled"
checked={config?.confirm?.enabled || false}
onCheckedChange={(checked) =>
onUpdate({
...config,
confirm: {
...config?.confirm,
enabled: checked === true,
},
})
}
/>
<Label htmlFor="confirmEnabled" className="text-xs">
</Label>
</div>
{config?.confirm?.enabled && (
<div>
<Input
value={config?.confirm?.message || ""}
onChange={(e) =>
onUpdate({
...config,
confirm: {
...config?.confirm,
enabled: true,
message: e.target.value,
},
})
}
placeholder="비워두면 기본 메시지 사용"
className="h-8 text-xs"
/>
<p className="text-[10px] text-muted-foreground mt-1">
:{" "}
{DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"]}
</p>
</div>
)}
</div>
{/* 후속 액션 */}
<SectionDivider label="후속 액션" />
<FollowUpActionsEditor
actions={config?.followUpActions || []}
onUpdate={(actions) =>
onUpdate({ ...config, followUpActions: actions })
}
/>
</div>
);
}
// ========================================
// 액션 세부 필드 (타입별)
// ========================================
function ActionDetailFields({
action,
onUpdate,
disabled,
}: {
action?: ButtonMainAction;
onUpdate: (updates: Partial<ButtonMainAction>) => void;
disabled?: boolean;
}) {
const actionType = action?.type || "save";
switch (actionType) {
case "save":
case "delete":
return (
<div>
<Label className="text-xs"> </Label>
<Input
value={action?.targetTable || ""}
onChange={(e) => onUpdate({ targetTable: e.target.value })}
placeholder="테이블명 입력"
className="h-8 text-xs"
disabled={disabled}
/>
</div>
);
case "api":
return (
<div className="space-y-2">
<div>
<Label className="text-xs"></Label>
<Input
value={action?.apiEndpoint || ""}
onChange={(e) => onUpdate({ apiEndpoint: e.target.value })}
placeholder="/api/..."
className="h-8 text-xs"
disabled={disabled}
/>
</div>
<div>
<Label className="text-xs">HTTP </Label>
<Select
value={action?.apiMethod || "POST"}
onValueChange={(v) =>
onUpdate({
apiMethod: v as "GET" | "POST" | "PUT" | "DELETE",
})
}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(API_METHOD_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
case "modal":
return (
<div className="space-y-2">
<div>
<Label className="text-xs"> </Label>
<Select
value={action?.modalMode || "fullscreen"}
onValueChange={(v) =>
onUpdate({ modalMode: v as ModalMode })
}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(MODAL_MODE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{action?.modalMode === "screen-ref" && (
<div>
<Label className="text-xs"> ID</Label>
<Input
value={action?.modalScreenId || ""}
onChange={(e) =>
onUpdate({ modalScreenId: e.target.value })
}
placeholder="화면 ID"
className="h-8 text-xs"
disabled={disabled}
/>
</div>
)}
<div>
<Label className="text-xs"> </Label>
<Input
value={action?.modalTitle || ""}
onChange={(e) => onUpdate({ modalTitle: e.target.value })}
placeholder="모달 제목 (선택)"
className="h-8 text-xs"
disabled={disabled}
/>
</div>
</div>
);
case "event":
return (
<div className="space-y-2">
<div>
<Label className="text-xs"></Label>
<Input
value={action?.eventName || ""}
onChange={(e) => onUpdate({ eventName: e.target.value })}
placeholder="예: data-saved, item-selected"
className="h-8 text-xs"
disabled={disabled}
/>
</div>
</div>
);
default:
return null;
}
}
// ========================================
// 후속 액션 편집기
// ========================================
function FollowUpActionsEditor({
actions,
onUpdate,
}: {
actions: FollowUpAction[];
onUpdate: (actions: FollowUpAction[]) => void;
}) {
// 추가
const handleAdd = () => {
onUpdate([...actions, { type: "event" }]);
};
// 삭제
const handleRemove = (index: number) => {
onUpdate(actions.filter((_, i) => i !== index));
};
// 수정
const handleUpdate = (index: number, updates: Partial<FollowUpAction>) => {
const newActions = [...actions];
newActions[index] = { ...newActions[index], ...updates };
onUpdate(newActions);
};
return (
<div className="space-y-2">
{actions.length === 0 && (
<p className="text-[10px] text-muted-foreground">
</p>
)}
{actions.map((fa, idx) => (
<div
key={idx}
className="space-y-1.5 rounded border p-2"
>
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium text-muted-foreground">
{idx + 1}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemove(idx)}
className="h-5 px-1 text-[10px] text-destructive"
>
</Button>
</div>
{/* 타입 선택 */}
<Select
value={fa.type}
onValueChange={(v) =>
handleUpdate(idx, { type: v as FollowUpActionType })
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FOLLOWUP_TYPE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 타입별 추가 입력 */}
{fa.type === "event" && (
<Input
value={fa.eventName || ""}
onChange={(e) =>
handleUpdate(idx, { eventName: e.target.value })
}
placeholder="이벤트명"
className="h-7 text-xs"
/>
)}
{fa.type === "navigate" && (
<Input
value={fa.targetScreenId || ""}
onChange={(e) =>
handleUpdate(idx, { targetScreenId: e.target.value })
}
placeholder="화면 ID"
className="h-7 text-xs"
/>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAdd}
className="w-full h-7 text-xs"
>
</Button>
</div>
);
}
// ========================================
// STEP 4: 미리보기 + 레지스트리 등록
// ========================================
function PopButtonPreviewComponent({
config,
}: {
config?: PopButtonConfig;
}) {
const buttonLabel = config?.label || "버튼";
const variant = config?.variant || "default";
const iconName = config?.icon || "";
const isIconOnly = config?.iconOnly || false;
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden">
<Button
variant={variant}
className={cn(
"pointer-events-none",
isIconOnly && "px-2"
)}
tabIndex={-1}
>
{iconName && (
<DynamicLucideIcon
name={iconName}
size={16}
className={isIconOnly ? "" : "mr-1.5"}
/>
)}
{!isIconOnly && <span>{buttonLabel}</span>}
</Button>
</div>
);
}
// 레지스트리 등록
PopComponentRegistry.registerComponent({
id: "pop-button",
name: "버튼",
description: "액션 버튼 (저장/삭제/API/모달/이벤트)",
category: "action",
icon: "MousePointerClick",
component: PopButtonComponent,
configPanel: PopButtonConfigPanel,
preview: PopButtonPreviewComponent,
defaultProps: {
label: "버튼",
variant: "default",
preset: "custom",
confirm: { enabled: false },
action: { type: "save" },
} as PopButtonConfig,
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});