999 lines
28 KiB
TypeScript
999 lines
28 KiB
TypeScript
"use client";
|
|
|
|
import { 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 { usePopAction } from "@/hooks/pop/usePopAction";
|
|
import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext";
|
|
import {
|
|
Save,
|
|
Trash2,
|
|
LogOut,
|
|
Menu,
|
|
ExternalLink,
|
|
Plus,
|
|
Check,
|
|
X,
|
|
Edit,
|
|
Search,
|
|
RefreshCw,
|
|
Download,
|
|
Upload,
|
|
Send,
|
|
Copy,
|
|
Settings,
|
|
ChevronDown,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
// ========================================
|
|
// STEP 1: 타입 정의
|
|
// ========================================
|
|
|
|
/** 메인 액션 타입 (5종) */
|
|
export type ButtonActionType = "save" | "delete" | "api" | "modal" | "event";
|
|
|
|
/** 후속 액션 타입 (4종) */
|
|
export type FollowUpActionType = "event" | "refresh" | "navigate" | "close-modal";
|
|
|
|
/** 버튼 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: "화면 이동",
|
|
"close-modal": "모달 닫기",
|
|
};
|
|
|
|
/** 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>
|
|
);
|
|
}
|
|
|
|
/** 허용된 아이콘 맵 (개별 import로 트리 쉐이킹 적용) */
|
|
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
|
|
Save, Trash2, LogOut, Menu, ExternalLink, Plus, Check, X,
|
|
Edit, Search, RefreshCw, Download, Upload, Send, Copy, Settings, ChevronDown,
|
|
};
|
|
|
|
/** Lucide 아이콘 동적 렌더링 */
|
|
function DynamicLucideIcon({
|
|
name,
|
|
size = 16,
|
|
className,
|
|
}: {
|
|
name: string;
|
|
size?: number;
|
|
className?: string;
|
|
}) {
|
|
const IconComponent = LUCIDE_ICON_MAP[name];
|
|
if (!IconComponent) return null;
|
|
return <IconComponent size={size} className={className} />;
|
|
}
|
|
|
|
// ========================================
|
|
// STEP 2: 메인 컴포넌트
|
|
// ========================================
|
|
|
|
interface PopButtonComponentProps {
|
|
config?: PopButtonConfig;
|
|
label?: string;
|
|
isDesignMode?: boolean;
|
|
screenId?: string;
|
|
}
|
|
|
|
export function PopButtonComponent({
|
|
config,
|
|
label,
|
|
isDesignMode,
|
|
screenId,
|
|
}: PopButtonComponentProps) {
|
|
// usePopAction 훅으로 액션 실행 통합
|
|
const {
|
|
execute,
|
|
isLoading,
|
|
pendingConfirm,
|
|
confirmExecute,
|
|
cancelConfirm,
|
|
} = usePopAction(screenId || "");
|
|
|
|
// 확인 메시지 결정
|
|
const getConfirmMessage = useCallback((): string => {
|
|
if (pendingConfirm?.confirm?.message) return pendingConfirm.confirm.message;
|
|
if (config?.confirm?.message) return config.confirm.message;
|
|
return DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"];
|
|
}, [pendingConfirm?.confirm?.message, config?.confirm?.message, config?.action?.type]);
|
|
|
|
// 클릭 핸들러
|
|
const handleClick = useCallback(async () => {
|
|
// 디자인 모드: 실제 실행 안 함
|
|
if (isDesignMode) {
|
|
toast.info(
|
|
`[디자인 모드] ${ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const action = config?.action;
|
|
if (!action) return;
|
|
|
|
await execute(action, {
|
|
confirm: config?.confirm,
|
|
followUpActions: config?.followUpActions,
|
|
});
|
|
}, [isDesignMode, config?.action, config?.confirm, config?.followUpActions, execute]);
|
|
|
|
// 외형
|
|
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}
|
|
disabled={isLoading}
|
|
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>
|
|
|
|
{/* 확인 다이얼로그 (usePopAction의 pendingConfirm 상태로 제어) */}
|
|
<AlertDialog open={!!pendingConfirm} onOpenChange={(open) => { if (!open) cancelConfirm(); }}>
|
|
<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={confirmExecute}
|
|
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">
|
|
프리셋에 의해 고정됨. 변경하려면 "직접 설정" 선택
|
|
</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;
|
|
}) {
|
|
// 디자이너 컨텍스트 (뷰어에서는 null)
|
|
const designerCtx = usePopDesignerContext();
|
|
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>
|
|
{/* 모달 캔버스 생성/열기 (fullscreen 모드 + 디자이너 내부) */}
|
|
{action?.modalMode === "fullscreen" && designerCtx && (
|
|
<div>
|
|
{action?.modalScreenId ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 w-full text-xs"
|
|
onClick={() => designerCtx.navigateToCanvas(action.modalScreenId!)}
|
|
>
|
|
모달 캔버스 열기
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 w-full text-xs"
|
|
onClick={() => {
|
|
const selectedId = designerCtx.selectedComponentId;
|
|
if (!selectedId) return;
|
|
const modalId = designerCtx.createModalCanvas(
|
|
selectedId,
|
|
action?.modalTitle || "새 모달"
|
|
);
|
|
onUpdate({ modalScreenId: modalId });
|
|
}}
|
|
>
|
|
모달 캔버스 생성
|
|
</Button>
|
|
)}
|
|
</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"],
|
|
});
|