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

1499 lines
49 KiB
TypeScript
Raw Normal View History

"use client";
import React, { useCallback, useState, useEffect, useMemo } 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 { DataFlowAPI } from "@/lib/api/dataflow";
import { usePopAction } from "@/hooks/pop/usePopAction";
import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import {
Save,
Trash2,
LogOut,
Menu,
ExternalLink,
Plus,
Check,
X,
Edit,
Search,
RefreshCw,
Download,
Upload,
Send,
Copy,
Settings,
ChevronDown,
ShoppingCart,
ShoppingBag,
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"
| "cart"
| "custom";
/** row_data 저장 모드 */
export type RowDataMode = "all" | "selected";
/** 장바구니 버튼 전용 설정 */
export interface CartButtonConfig {
cartScreenId?: string;
rowDataMode?: RowDataMode;
selectedColumns?: string[];
}
/** pop-button 전체 설정 */
export interface PopButtonConfig {
label: string;
variant: ButtonVariant;
icon?: string;
iconOnly?: boolean;
preset: ButtonPreset;
confirm?: ConfirmConfig;
action: ButtonMainAction;
followUpActions?: FollowUpAction[];
cart?: CartButtonConfig;
}
// ========================================
// 상수
// ========================================
/** 메인 액션 타입 라벨 */
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": "모달 열기",
cart: "장바구니 저장",
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)" },
{ value: "ShoppingCart", label: "장바구니 (ShoppingCart)" },
{ value: "ShoppingBag", label: "장바구니 담김 (ShoppingBag)" },
];
/** 프리셋별 기본 설정 */
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" },
},
cart: {
label: "장바구니 저장",
variant: "default",
icon: "ShoppingCart",
confirm: { enabled: true, message: "장바구니에 담은 항목을 저장하시겠습니까?" },
action: { type: "event" },
},
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>
);
}
/** 장바구니 데이터 매핑 행 (읽기 전용) */
function CartMappingRow({
source,
target,
desc,
auto,
}: {
source: string;
target: string;
desc?: string;
auto?: boolean;
}) {
return (
<div className="flex items-start gap-1 py-0.5">
<span className={cn("min-w-0 flex-1 text-[10px]", auto ? "text-muted-foreground" : "text-foreground")}>
{source}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground">&rarr;</span>
<div className="shrink-0 text-right">
<code className="rounded bg-muted px-1 font-mono text-[10px] text-foreground">
{target}
</code>
{desc && (
<p className="mt-0.5 text-[9px] text-muted-foreground leading-tight">{desc}</p>
)}
</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,
ShoppingCart,
ShoppingBag,
};
/** 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;
componentId?: string;
}
export function PopButtonComponent({
config,
label,
isDesignMode,
screenId,
componentId,
}: PopButtonComponentProps) {
const {
execute,
isLoading,
pendingConfirm,
confirmExecute,
cancelConfirm,
} = usePopAction(screenId || "");
const { subscribe, publish } = usePopEvent(screenId || "default");
// 장바구니 모드 상태
const isCartMode = config?.preset === "cart";
const [cartCount, setCartCount] = useState(0);
const [cartIsDirty, setCartIsDirty] = useState(false);
const [cartSaving, setCartSaving] = useState(false);
const [showCartConfirm, setShowCartConfirm] = useState(false);
// 장바구니 상태 수신 (카드 목록에서 count/isDirty 전달)
useEffect(() => {
if (!isCartMode || !componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__cart_updated`,
(payload: unknown) => {
const data = payload as { value?: { count?: number; isDirty?: boolean } } | undefined;
const inner = data?.value;
if (inner?.count !== undefined) setCartCount(inner.count);
if (inner?.isDirty !== undefined) setCartIsDirty(inner.isDirty);
}
);
return unsub;
}, [isCartMode, componentId, subscribe]);
// 저장 완료 수신 (카드 목록에서 saveToDb 완료 후 전달)
const cartScreenIdRef = React.useRef(config?.cart?.cartScreenId);
cartScreenIdRef.current = config?.cart?.cartScreenId;
useEffect(() => {
if (!isCartMode || !componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__cart_save_completed`,
(payload: unknown) => {
const data = payload as { value?: { success?: boolean } } | undefined;
setCartSaving(false);
if (data?.value?.success) {
setCartIsDirty(false);
const targetScreenId = cartScreenIdRef.current;
if (targetScreenId) {
const cleanId = targetScreenId.replace(/^.*\/(\d+)$/, "$1").trim();
window.location.href = `/pop/screens/${cleanId}`;
} else {
toast.success("장바구니가 저장되었습니다.");
}
} else {
toast.error("장바구니 저장에 실패했습니다.");
}
}
);
return unsub;
}, [isCartMode, componentId, subscribe]);
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]);
// 장바구니 저장 트리거 (연결 미설정 시 10초 타임아웃으로 복구)
const cartSaveTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCartSave = useCallback(() => {
if (!componentId) return;
setCartSaving(true);
const selectedCols =
config?.cart?.rowDataMode === "selected" ? config.cart.selectedColumns : undefined;
publish(`__comp_output__${componentId}__cart_save_trigger`, {
selectedColumns: selectedCols,
});
if (cartSaveTimeoutRef.current) clearTimeout(cartSaveTimeoutRef.current);
cartSaveTimeoutRef.current = setTimeout(() => {
setCartSaving((prev) => {
if (prev) {
toast.error("장바구니 저장 응답이 없습니다. 연결 설정을 확인하세요.");
}
return false;
});
}, 10_000);
}, [componentId, publish, config?.cart?.rowDataMode, config?.cart?.selectedColumns]);
// 저장 완료 시 타임아웃 정리
useEffect(() => {
if (!cartSaving && cartSaveTimeoutRef.current) {
clearTimeout(cartSaveTimeoutRef.current);
cartSaveTimeoutRef.current = null;
}
}, [cartSaving]);
// 클릭 핸들러
const handleClick = useCallback(async () => {
if (isDesignMode) {
toast.info(
`[디자인 모드] ${isCartMode ? "장바구니 저장" : ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션`
);
return;
}
// 장바구니 모드: isDirty 여부에 따라 분기
if (isCartMode) {
if (cartCount === 0 && !cartIsDirty) {
toast.info("장바구니가 비어 있습니다.");
return;
}
if (cartIsDirty) {
// 새로 담은 항목이 있음 → 확인 후 저장
setShowCartConfirm(true);
} else {
// 이미 저장된 상태 → 바로 장바구니 화면 이동
const targetScreenId = config?.cart?.cartScreenId;
if (targetScreenId) {
const cleanId = targetScreenId.replace(/^.*\/(\d+)$/, "$1").trim();
window.location.href = `/pop/screens/${cleanId}`;
} else {
toast.info("장바구니 화면이 설정되지 않았습니다.");
}
}
return;
}
const action = config?.action;
if (!action) return;
await execute(action, {
confirm: config?.confirm,
followUpActions: config?.followUpActions,
});
}, [isDesignMode, isCartMode, config, cartCount, cartIsDirty, execute, handleCartSave]);
// 외형
const buttonLabel = config?.label || label || "버튼";
const variant = config?.variant || "default";
const iconName = config?.icon || "";
const isIconOnly = config?.iconOnly || false;
// 장바구니 3상태 아이콘: 빈 장바구니 / 저장 완료 / 변경사항 있음
const cartIconName = useMemo(() => {
if (!isCartMode) return iconName;
if (cartCount === 0 && !cartIsDirty) return "ShoppingCart";
if (cartCount > 0 && !cartIsDirty) return "ShoppingBag";
return "ShoppingCart";
}, [isCartMode, cartCount, cartIsDirty, iconName]);
// 장바구니 3상태 버튼 색상
const cartButtonClass = useMemo(() => {
if (!isCartMode) return "";
if (cartCount > 0 && !cartIsDirty) {
return "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600";
}
if (cartIsDirty) {
return "bg-orange-500 hover:bg-orange-600 text-white border-orange-500 animate-pulse";
}
return "";
}, [isCartMode, cartCount, cartIsDirty]);
return (
<>
<div className="flex h-full w-full items-center justify-center">
<div className="relative">
<Button
variant={variant}
onClick={handleClick}
disabled={isLoading || cartSaving}
className={cn(
"transition-transform active:scale-95",
isIconOnly && "px-2",
cartButtonClass,
)}
>
{(isCartMode ? cartIconName : iconName) && (
<DynamicLucideIcon
name={isCartMode ? cartIconName : iconName}
size={16}
className={isIconOnly ? "" : "mr-1.5"}
/>
)}
{!isIconOnly && <span>{buttonLabel}</span>}
</Button>
{/* 장바구니 배지 */}
{isCartMode && cartCount > 0 && (
<div
className={cn(
"absolute -top-2 -right-2 flex items-center justify-center rounded-full text-[10px] font-bold",
cartIsDirty
? "bg-orange-500 text-white"
: "bg-emerald-600 text-white",
)}
style={{ minWidth: 18, height: 18, padding: "0 4px" }}
>
{cartCount}
</div>
)}
</div>
</div>
{/* 장바구니 확인 다이얼로그 */}
<AlertDialog open={showCartConfirm} onOpenChange={setShowCartConfirm}>
<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">
{config?.confirm?.message || `${cartCount}개 항목을 장바구니에 저장하시겠습니까?`}
</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={() => {
setShowCartConfirm(false);
handleCartSave();
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일반 확인 다이얼로그 */}
<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;
allComponents?: { id: string; type: string; config?: Record<string, unknown> }[];
connections?: { sourceComponent: string; targetComponent: string; sourceOutput?: string; targetInput?: string }[];
componentId?: string;
}
export function PopButtonConfigPanel({
config,
onUpdate,
allComponents,
connections,
componentId,
}: PopButtonConfigPanelProps) {
const isCustom = config?.preset === "custom";
// 컬럼 불러오기용 상태
const [loadedColumns, setLoadedColumns] = useState<{ name: string; label: string }[]>([]);
const [colLoading, setColLoading] = useState(false);
const [connectedTableName, setConnectedTableName] = useState<string | null>(null);
// 연결된 카드 목록의 테이블명 자동 탐색
useEffect(() => {
if (config?.preset !== "cart" || !componentId || !connections || !allComponents) {
setConnectedTableName(null);
return;
}
// 방법 1: 버튼(source) -> 카드목록(target) cart_save_trigger 연결
let cardListId: string | undefined;
const outConn = connections.find(
(c) =>
c.sourceComponent === componentId &&
c.sourceOutput === "cart_save_trigger",
);
if (outConn) {
cardListId = outConn.targetComponent;
}
// 방법 2: 카드목록(source) -> 버튼(target) cart_updated 연결 (역방향)
if (!cardListId) {
const inConn = connections.find(
(c) =>
c.targetComponent === componentId &&
(c.sourceOutput === "cart_updated" || c.sourceOutput === "cart_save_completed"),
);
if (inConn) {
cardListId = inConn.sourceComponent;
}
}
// 방법 3: 버튼과 연결된 pop-card-list 타입 컴포넌트 탐색
if (!cardListId) {
const anyConn = connections.find(
(c) =>
(c.sourceComponent === componentId || c.targetComponent === componentId),
);
if (anyConn) {
const otherId = anyConn.sourceComponent === componentId
? anyConn.targetComponent
: anyConn.sourceComponent;
const otherComp = allComponents.find((c) => c.id === otherId);
if (otherComp?.type === "pop-card-list") {
cardListId = otherId;
}
}
}
if (!cardListId) {
setConnectedTableName(null);
return;
}
const cardList = allComponents.find((c) => c.id === cardListId);
const cfg = cardList?.config as Record<string, unknown> | undefined;
const dataSource = cfg?.dataSource as Record<string, unknown> | undefined;
const tableName = (dataSource?.tableName as string) || (cfg?.tableName as string) || undefined;
setConnectedTableName(tableName || null);
}, [config?.preset, componentId, connections, allComponents]);
// 선택 저장 모드 + 연결 테이블명이 있으면 컬럼 자동 로드
useEffect(() => {
if (config?.cart?.rowDataMode !== "selected" || !connectedTableName) {
return;
}
// 이미 같은 테이블의 컬럼이 로드되어 있으면 스킵
if (loadedColumns.length > 0) return;
let cancelled = false;
setColLoading(true);
DataFlowAPI.getTableColumns(connectedTableName)
.then((cols) => {
if (cancelled) return;
setLoadedColumns(
cols
.filter((c: { columnName: string }) =>
!["id", "created_at", "updated_at", "created_by", "updated_by"].includes(c.columnName),
)
.map((c: { columnName: string; displayName?: string }) => ({
name: c.columnName,
label: c.displayName || c.columnName,
})),
);
})
.catch(() => {
if (!cancelled) setLoadedColumns([]);
})
.finally(() => {
if (!cancelled) setColLoading(false);
});
return () => { cancelled = true; };
}, [config?.cart?.rowDataMode, connectedTableName, loadedColumns.length]);
// 프리셋 변경 핸들러
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>
{/* 장바구니 설정 (cart 프리셋 전용) */}
{config?.preset === "cart" && (
<>
<SectionDivider label="장바구니 설정" />
<div className="space-y-3">
<div>
<Label className="text-xs"> ID</Label>
<Input
value={config?.cart?.cartScreenId || ""}
onChange={(e) =>
onUpdate({
...config,
cart: { ...config.cart, cartScreenId: e.target.value },
})
}
placeholder="저장 후 이동할 POP 화면 ID"
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-[10px]">
ID입니다.
.
</p>
</div>
</div>
{/* 데이터 저장 흐름 시각화 */}
<SectionDivider label="데이터 저장 흐름" />
<div className="space-y-2">
<p className="text-muted-foreground text-[10px]">
&quot;&quot; <code className="rounded bg-muted px-1 font-mono text-foreground">cart_items</code> .
</p>
<div className="space-y-0.5">
{/* 사용자 입력 데이터 */}
<div className="rounded-md border bg-amber-50/50 px-2.5 py-1.5 dark:bg-amber-950/20">
<p className="mb-1 text-[10px] font-medium text-amber-700 dark:text-amber-400"> </p>
<CartMappingRow source="입력한 수량" target="quantity" />
<CartMappingRow source="포장 단위" target="package_unit" />
<CartMappingRow source="포장 내역 (JSON)" target="package_entries" />
<CartMappingRow source="메모" target="memo" />
</div>
{/* 원본 데이터 */}
<div className="rounded-md border bg-blue-50/50 px-2.5 py-1.5 dark:bg-blue-950/20">
<p className="mb-1 text-[10px] font-medium text-blue-700 dark:text-blue-400"> </p>
{/* 저장 모드 선택 */}
<div className="mb-1.5 flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground"> :</span>
<Select
value={config?.cart?.rowDataMode || "all"}
onValueChange={(v) =>
onUpdate({
...config,
cart: { ...config.cart, rowDataMode: v as RowDataMode },
})
}
>
<SelectTrigger className="h-6 w-[100px] text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs"> </SelectItem>
<SelectItem value="selected" className="text-xs"> </SelectItem>
</SelectContent>
</Select>
</div>
{config?.cart?.rowDataMode === "selected" ? (
<>
{/* 선택 저장 모드: 컬럼 목록 관리 */}
<div className="space-y-1.5">
{connectedTableName ? (
<p className="text-[10px] text-muted-foreground">
: <code className="rounded bg-muted px-1 font-mono text-foreground">{connectedTableName}</code>
</p>
) : (
<p className="text-[9px] text-amber-600 dark:text-amber-400">
(cart_save_trigger) .
</p>
)}
{colLoading && (
<p className="text-[9px] text-muted-foreground"> ...</p>
)}
{/* 불러온 컬럼 체크박스 */}
{loadedColumns.length > 0 && (
<div className="max-h-[160px] space-y-0.5 overflow-y-auto rounded border bg-background p-1.5">
{loadedColumns.map((col) => {
const isChecked = (config?.cart?.selectedColumns || []).includes(col.name);
return (
<label key={col.name} className="flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 hover:bg-muted/50">
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => {
const prev = config?.cart?.selectedColumns || [];
const next = checked
? [...prev, col.name]
: prev.filter((c) => c !== col.name);
onUpdate({
...config,
cart: { ...config.cart, selectedColumns: next },
});
}}
className="h-3 w-3"
/>
<span className="text-[10px]">{col.label}</span>
{col.label !== col.name && (
<span className="text-[9px] text-muted-foreground">({col.name})</span>
)}
</label>
);
})}
</div>
)}
{/* 선택된 컬럼 요약 */}
{(config?.cart?.selectedColumns?.length ?? 0) > 0 ? (
<CartMappingRow
source={`선택된 ${config!.cart!.selectedColumns!.length}개 컬럼 (JSON)`}
target="row_data"
desc={config!.cart!.selectedColumns!.join(", ")}
/>
) : (
<p className="text-[9px] text-amber-600 dark:text-amber-400">
. .
</p>
)}
</div>
</>
) : (
<CartMappingRow source="행 전체 (JSON)" target="row_data" desc="원본 테이블의 모든 컬럼이 JSON으로 저장" />
)}
<CartMappingRow source="행 식별키 (PK)" target="row_key" />
<CartMappingRow source="원본 테이블명" target="source_table" />
</div>
{/* 시스템 자동 */}
<div className="rounded-md border bg-muted/30 px-2.5 py-1.5">
<p className="mb-1 text-[10px] font-medium text-muted-foreground"> </p>
<CartMappingRow source="현재 화면 ID" target="screen_id" auto />
<CartMappingRow source='장바구니 타입 ("pop")' target="cart_type" auto />
<CartMappingRow source='상태 ("in_cart")' target="status" auto />
<CartMappingRow source="회사 코드" target="company_code" auto />
<CartMappingRow source="사용자 ID" target="user_id" auto />
</div>
</div>
<p className="text-muted-foreground text-[10px] leading-relaxed">
<code className="rounded bg-muted px-1 font-mono text-foreground">row_data</code> JSON을
.
</p>
</div>
</>
)}
{/* 메인 액션 (cart 프리셋에서는 숨김) */}
{config?.preset !== "cart" && (
<>
<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;
}) {
// 디자이너 컨텍스트 (뷰어에서는 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,
connectionMeta: {
sendable: [
{ key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 일괄 저장 요청 (카드 목록에 전달)" },
],
receivable: [
{ key: "cart_updated", label: "장바구니 상태", type: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" },
{ key: "cart_save_completed", label: "저장 완료", type: "event", description: "카드 목록에서 DB 저장 완료 시 수신" },
],
},
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});