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

2681 lines
90 KiB
TypeScript

"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 { usePopAction } from "@/hooks/pop/usePopAction";
import { executeTaskList, type CollectedPayload } from "@/hooks/pop/executePopAction";
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,
PackageCheck,
ChevronRight,
GripVertical,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
import type { CollectedDataResponse, StatusChangeRule, ConditionalValue } from "./types";
import { apiClient } from "@/lib/api/client";
import { TableCombobox } from "./pop-shared/TableCombobox";
import { ColumnCombobox } from "./pop-shared/ColumnCombobox";
import {
fetchTableList,
fetchTableColumns,
type TableInfo,
type ColumnInfo,
} from "./pop-dashboard/utils/dataFetcher";
// ========================================
// 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"
| "inbound-confirm"
| "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;
statusChangeRules?: StatusChangeRule[];
/** @deprecated inboundConfirm.statusChangeRules -> statusChangeRules로 이동 */
inboundConfirm?: { statusChangeRules?: StatusChangeRule[] };
}
// ========================================
// STEP 1-B: 통합 작업 목록 타입 (v2)
// ========================================
/** 작업 타입 (10종) */
export type ButtonTaskType =
| "data-save"
| "data-update"
| "data-delete"
| "cart-save"
| "modal-open"
| "navigate"
| "close-modal"
| "refresh"
| "api-call"
| "custom-event";
/** 데이터 수정 연산 */
export type UpdateOperationType = "assign" | "add" | "subtract" | "multiply" | "divide" | "conditional" | "db-conditional";
/** 데이터 수정 값 출처 */
export type UpdateValueSource = "fixed" | "linked" | "reference";
/** 작업 1건 설정 */
export interface ButtonTask {
id: string;
type: ButtonTaskType;
label?: string;
// data-update / data-delete
targetTable?: string;
targetColumn?: string;
operationType?: UpdateOperationType;
valueSource?: UpdateValueSource;
fixedValue?: string;
sourceField?: string;
referenceTable?: string;
referenceColumn?: string;
referenceJoinKey?: string;
conditionalValue?: ConditionalValue;
// db-conditional (DB 컬럼 간 비교 후 값 판정)
compareColumn?: string;
compareOperator?: "=" | "!=" | ">" | "<" | ">=" | "<=";
compareWith?: string;
dbThenValue?: string;
dbElseValue?: string;
lookupMode?: "auto" | "manual";
manualItemField?: string;
manualPkColumn?: string;
// cart-save
cartScreenId?: string;
// modal-open
modalMode?: ModalMode;
modalScreenId?: string;
modalTitle?: string;
modalItems?: ModalMenuItem[];
// navigate
targetScreenId?: string;
params?: Record<string, string>;
// api-call
apiEndpoint?: string;
apiMethod?: "GET" | "POST" | "PUT" | "DELETE";
// custom-event
eventName?: string;
eventPayload?: Record<string, unknown>;
}
/** 빠른 시작 템플릿 */
export type QuickStartTemplate = "save" | "delete" | "confirm" | "cart" | "modal" | "custom";
/** pop-button 설정 v2 (작업 목록 기반) */
export interface PopButtonConfigV2 {
label: string;
variant: ButtonVariant;
icon?: string;
iconOnly?: boolean;
confirm?: ConfirmConfig;
tasks: ButtonTask[];
}
/** 기존 config(v1) → v2 변환. tasks 필드가 이미 있으면 그대로 반환. */
export function migrateButtonConfig(old: PopButtonConfig): PopButtonConfigV2 {
if ("tasks" in old && Array.isArray((old as unknown as PopButtonConfigV2).tasks)) {
return old as unknown as PopButtonConfigV2;
}
const tasks: ButtonTask[] = [];
let tid = 1;
const nextId = () => `t${tid++}`;
// 메인 액션 → task 변환
if (old.preset === "cart") {
tasks.push({ id: nextId(), type: "cart-save", cartScreenId: old.cart?.cartScreenId });
} else if (old.action?.type === "modal") {
tasks.push({
id: nextId(),
type: "modal-open",
modalMode: old.action.modalMode,
modalScreenId: old.action.modalScreenId,
modalTitle: old.action.modalTitle,
modalItems: old.action.modalItems,
});
} else if (old.action?.type === "delete") {
tasks.push({ id: nextId(), type: "data-delete", targetTable: old.action.targetTable });
} else if (old.action?.type === "api") {
tasks.push({
id: nextId(),
type: "api-call",
apiEndpoint: old.action.apiEndpoint,
apiMethod: old.action.apiMethod,
});
} else if (old.action?.type === "event" && old.preset !== "inbound-confirm") {
tasks.push({
id: nextId(),
type: "custom-event",
eventName: old.action.eventName,
eventPayload: old.action.eventPayload,
});
} else {
// save / inbound-confirm / 기본
tasks.push({ id: nextId(), type: "data-save" });
}
// 상태변경 규칙 → data-update task
const rules = old.statusChangeRules ?? old.inboundConfirm?.statusChangeRules ?? [];
for (const rule of rules) {
tasks.push({
id: nextId(),
type: "data-update",
targetTable: rule.targetTable,
targetColumn: rule.targetColumn,
operationType: "assign",
valueSource: rule.valueType === "conditional" ? "fixed" : "fixed",
fixedValue: rule.fixedValue,
conditionalValue: rule.conditionalValue,
lookupMode: rule.lookupMode,
manualItemField: rule.manualItemField,
manualPkColumn: rule.manualPkColumn,
});
}
// 후속 액션 → task
for (const fa of old.followUpActions ?? []) {
switch (fa.type) {
case "refresh":
tasks.push({ id: nextId(), type: "refresh" });
break;
case "navigate":
tasks.push({ id: nextId(), type: "navigate", targetScreenId: fa.targetScreenId, params: fa.params });
break;
case "close-modal":
tasks.push({ id: nextId(), type: "close-modal" });
break;
case "event":
tasks.push({ id: nextId(), type: "custom-event", eventName: fa.eventName, eventPayload: fa.eventPayload });
break;
}
}
return {
label: old.label,
variant: old.variant,
icon: old.icon,
iconOnly: old.iconOnly,
confirm: old.confirm,
tasks,
};
}
/** 작업 타입 한글 라벨 */
export const TASK_TYPE_LABELS: Record<ButtonTaskType, string> = {
"data-save": "데이터 저장",
"data-update": "데이터 수정",
"data-delete": "데이터 삭제",
"cart-save": "장바구니 저장",
"modal-open": "모달 열기",
"navigate": "페이지 이동",
"close-modal": "모달 닫기",
"refresh": "새로고침",
"api-call": "API 호출",
"custom-event": "커스텀 이벤트",
};
/** 빠른 시작 템플릿별 기본 작업 목록 + 외형 */
export const QUICK_START_DEFAULTS: Record<QuickStartTemplate, { label: string; variant: ButtonVariant; icon: string; confirm?: ConfirmConfig; tasks: ButtonTask[] }> = {
save: {
label: "저장",
variant: "default",
icon: "Save",
confirm: { enabled: false },
tasks: [{ id: "t1", type: "data-save" }],
},
delete: {
label: "삭제",
variant: "destructive",
icon: "Trash2",
confirm: { enabled: true, message: "삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." },
tasks: [{ id: "t1", type: "data-delete" }],
},
confirm: {
label: "확정",
variant: "default",
icon: "PackageCheck",
confirm: { enabled: true, message: "선택한 항목을 확정하시겠습니까?" },
tasks: [
{ id: "t1", type: "data-save" },
{ id: "t2", type: "data-update" },
{ id: "t3", type: "refresh" },
],
},
cart: {
label: "장바구니 저장",
variant: "default",
icon: "ShoppingCart",
confirm: { enabled: true, message: "장바구니에 담은 항목을 저장하시겠습니까?" },
tasks: [{ id: "t1", type: "cart-save" }],
},
modal: {
label: "열기",
variant: "outline",
icon: "ExternalLink",
confirm: { enabled: false },
tasks: [{ id: "t1", type: "modal-open" }],
},
custom: {
label: "버튼",
variant: "default",
icon: "none",
confirm: { enabled: false },
tasks: [],
},
};
// ========================================
// 상수
// ========================================
/** 메인 액션 타입 라벨 */
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: "장바구니 저장",
"inbound-confirm": "입고 확정",
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" },
},
"inbound-confirm": {
label: "입고 확정",
variant: "default",
icon: "PackageCheck",
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,
PackageCheck,
ChevronRight,
GripVertical,
};
/** 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 isInboundConfirmMode = config?.preset === "inbound-confirm";
const [cartCount, setCartCount] = useState(0);
const [cartIsDirty, setCartIsDirty] = useState(false);
const [cartSaving, setCartSaving] = useState(false);
const [showCartConfirm, setShowCartConfirm] = useState(false);
const [confirmProcessing, setConfirmProcessing] = useState(false);
const [showInboundConfirm, setShowInboundConfirm] = useState(false);
const [inboundSelectedCount, setInboundSelectedCount] = useState(0);
const [generatedCodesResult, setGeneratedCodesResult] = useState<Array<{ targetColumn: string; code: string; showResultModal?: boolean }>>([]);
const [deferredV2Tasks, setDeferredV2Tasks] = useState<Array<{ type: string; targetScreenId?: string; params?: Record<string, string> }>>([]);
const handleCloseGeneratedCodesModal = useCallback(() => {
setGeneratedCodesResult([]);
toast.success("작업 완료");
publish(`__comp_output__${componentId}__action_completed`, {
action: "task-list",
success: true,
});
// v2 보류된 작업 실행
if (deferredV2Tasks.length > 0) {
for (const task of deferredV2Tasks) {
if (task.type === "navigate" && task.targetScreenId) {
publish("__pop_navigate__", { screenId: task.targetScreenId, params: task.params });
}
}
setDeferredV2Tasks([]);
return;
}
// v1 후속 액션
const followUps = config?.followUpActions ?? [];
for (const fa of followUps) {
switch (fa.type) {
case "navigate":
if (fa.targetScreenId) {
publish("__pop_navigate__", { screenId: fa.targetScreenId, params: fa.params });
}
break;
case "refresh":
publish("__pop_refresh__");
break;
case "event":
if (fa.eventName) publish(fa.eventName, fa.eventPayload);
break;
}
}
}, [componentId, publish, config?.followUpActions, deferredV2Tasks]);
// v2 작업 목록 감지 (선택 항목 구독보다 먼저 선언)
const v2Config = useMemo(() => {
if (!config) return null;
if ("tasks" in config && Array.isArray((config as unknown as PopButtonConfigV2).tasks)) {
return config as unknown as PopButtonConfigV2;
}
return null;
}, [config]);
// 선택 항목 수 수신 (v1 inbound-confirm + v2 모두 활성)
const hasDataTasks = useMemo(() => {
if (isInboundConfirmMode) return true;
if (!v2Config) return false;
return v2Config.tasks.some((t) => t.type === "data-save" || t.type === "data-update");
}, [isInboundConfirmMode, v2Config]);
useEffect(() => {
if (!hasDataTasks || !componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__selected_items`,
(payload: unknown) => {
const data = payload as { value?: unknown[] } | undefined;
const items = Array.isArray(data?.value) ? data.value : [];
setInboundSelectedCount(items.length);
}
);
return unsub;
}, [hasDataTasks, componentId, subscribe]);
// 장바구니 상태 수신 (카드 목록에서 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]);
// 입고 확정: 데이터 수집 → API 호출
const handleInboundConfirm = useCallback(async () => {
if (!componentId) return;
setConfirmProcessing(true);
try {
// 동기적 이벤트 수집 (connectionResolver가 동기 중계)
const responses: CollectedDataResponse[] = [];
const unsub = subscribe(
`__comp_input__${componentId}__collected_data`,
(payload: unknown) => {
const enriched = payload as { value?: CollectedDataResponse };
if (enriched?.value) {
responses.push(enriched.value);
}
}
);
publish(`__comp_output__${componentId}__collect_data`, {
requestId: crypto.randomUUID(),
action: "inbound-confirm",
});
unsub();
if (responses.length === 0) {
toast.error("연결된 컴포넌트에서 데이터를 수집할 수 없습니다. 연결 설정을 확인하세요.");
return;
}
const cardListData = responses.find(r => r.componentType === "pop-card-list");
const fieldData = responses.find(r => r.componentType === "pop-field");
const selectedItems = cardListData?.data?.items ?? [];
if (selectedItems.length === 0) {
toast.error("확정할 항목을 선택해주세요.");
return;
}
const fieldValues = fieldData?.data?.values ?? {};
const statusChangeRules = config?.statusChangeRules ?? config?.inboundConfirm?.statusChangeRules ?? [];
const cardListMapping = cardListData?.mapping ?? null;
const fieldMapping = fieldData?.mapping ?? null;
const result = await apiClient.post("/pop/execute-action", {
action: "inbound-confirm",
data: {
items: selectedItems,
fieldValues,
},
mappings: {
cardList: cardListMapping,
field: fieldMapping,
},
statusChanges: statusChangeRules,
});
if (result.data?.success) {
const codes: Array<{ targetColumn: string; code: string; showResultModal?: boolean }> = result.data.data?.generatedCodes ?? [];
const modalCodes = codes.filter((c) => c.showResultModal);
if (modalCodes.length > 0) {
setGeneratedCodesResult(modalCodes);
} else {
toast.success(`${selectedItems.length}건 입고 확정 완료`);
publish(`__comp_output__${componentId}__action_completed`, {
action: "inbound-confirm",
success: true,
count: selectedItems.length,
});
const followUps = config?.followUpActions ?? [];
for (const fa of followUps) {
switch (fa.type) {
case "navigate":
if (fa.targetScreenId) {
publish("__pop_navigate__", { screenId: fa.targetScreenId, params: fa.params });
}
break;
case "refresh":
publish("__pop_refresh__");
break;
case "event":
if (fa.eventName) publish(fa.eventName, fa.eventPayload);
break;
}
}
}
} else {
toast.error(result.data?.message || "입고 확정에 실패했습니다.");
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "입고 확정 중 오류가 발생했습니다.";
toast.error(message);
} finally {
setConfirmProcessing(false);
setShowInboundConfirm(false);
}
}, [componentId, subscribe, publish, config?.statusChangeRules, config?.inboundConfirm?.statusChangeRules, config?.followUpActions]);
// v2: 데이터 수집 → executeTaskList 호출
const handleV2Execute = useCallback(async () => {
if (!v2Config || !componentId) return;
setConfirmProcessing(true);
try {
const responses: CollectedDataResponse[] = [];
const unsub = subscribe(
`__comp_input__${componentId}__collected_data`,
(payload: unknown) => {
const enriched = payload as { value?: CollectedDataResponse };
if (enriched?.value) responses.push(enriched.value);
},
);
publish(`__comp_output__${componentId}__collect_data`, {
requestId: crypto.randomUUID(),
action: "task-list",
});
unsub();
const cardListData = responses.find((r) => r.componentType === "pop-card-list");
const fieldData = responses.find((r) => r.componentType === "pop-field");
const collectedData: CollectedPayload = {
items: cardListData?.data?.items ?? [],
fieldValues: fieldData?.data?.values ?? {},
mappings: {
cardList: cardListData?.mapping ?? null,
field: fieldData?.mapping ?? null,
},
cartChanges: (cardListData?.data as Record<string, unknown>)?.cartChanges as CollectedPayload["cartChanges"],
};
const result = await executeTaskList(v2Config.tasks, {
publish,
componentId,
collectedData,
});
if (result.success) {
const resultData = result.data as Record<string, unknown> | undefined;
const generatedCodes = resultData?.generatedCodes as
Array<{ targetColumn: string; code: string; showResultModal?: boolean }> | undefined;
const deferred = resultData?.deferredTasks as
Array<{ type: string; targetScreenId?: string; params?: Record<string, string> }> | undefined;
if (generatedCodes?.some((g) => g.showResultModal)) {
setGeneratedCodesResult(generatedCodes);
if (deferred && deferred.length > 0) {
setDeferredV2Tasks(deferred);
}
} else {
toast.success("작업 완료");
}
} else {
toast.error(result.error || "작업 실행에 실패했습니다.");
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "작업 실행 중 오류가 발생했습니다.";
toast.error(message);
} finally {
setConfirmProcessing(false);
}
}, [v2Config, componentId, subscribe, publish]);
// 클릭 핸들러
const handleClick = useCallback(async () => {
if (isDesignMode) {
const modeLabel = v2Config
? v2Config.tasks.map((t) => TASK_TYPE_LABELS[t.type]).join(" → ")
: isCartMode ? "장바구니 저장" : isInboundConfirmMode ? "입고 확정" : ACTION_TYPE_LABELS[config?.action?.type || "save"];
toast.info(`[디자인 모드] ${modeLabel}`);
return;
}
// v2 경로: tasks 배열이 있으면 새 실행 엔진 사용
if (v2Config) {
if (v2Config.confirm?.enabled) {
setShowInboundConfirm(true);
} else {
await handleV2Execute();
}
return;
}
// === 이하 v1 레거시 경로 ===
// 입고 확정 모드: confirm 다이얼로그 후 데이터 수집 → API 호출
if (isInboundConfirmMode) {
if (config?.confirm?.enabled !== false) {
setShowInboundConfirm(true);
} else {
await handleInboundConfirm();
}
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, v2Config, isCartMode, isInboundConfirmMode, config, cartCount, cartIsDirty, execute, handleCartSave, handleInboundConfirm, handleV2Execute]);
// 외형
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]);
// 데이터 작업 버튼 2상태 아이콘: 미선택(기본) / 선택됨(아이콘 유지)
const inboundIconName = useMemo(() => {
if (!hasDataTasks && !isInboundConfirmMode) return iconName;
return config?.icon || iconName || "PackageCheck";
}, [hasDataTasks, isInboundConfirmMode, config?.icon, iconName]);
// 데이터 작업 버튼 2상태 색상: 미선택(기본) / 선택됨(초록)
const inboundButtonClass = useMemo(() => {
if (isCartMode) return "";
return inboundSelectedCount > 0
? "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600"
: "";
}, [isCartMode, inboundSelectedCount]);
return (
<>
<div className="flex h-full w-full items-center justify-center">
<div className="relative">
<Button
variant={variant}
onClick={handleClick}
disabled={isLoading || cartSaving || confirmProcessing}
className={cn(
"transition-transform active:scale-95",
isIconOnly && "px-2",
cartButtonClass,
inboundButtonClass,
)}
>
{(isCartMode ? cartIconName : hasDataTasks ? inboundIconName : iconName) && (
<DynamicLucideIcon
name={isCartMode ? cartIconName : hasDataTasks ? inboundIconName : 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>
)}
{/* 선택 개수 배지 (v1 inbound-confirm + v2 data tasks) */}
{!isCartMode && hasDataTasks && inboundSelectedCount > 0 && (
<div
className="absolute -top-2 -right-2 flex items-center justify-center rounded-full bg-emerald-600 text-white text-[10px] font-bold"
style={{ minWidth: 18, height: 18, padding: "0 4px" }}
>
{inboundSelectedCount}
</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>
{/* 확정/실행 확인 다이얼로그 (v2 + v1 입고확정 공용) */}
<AlertDialog open={showInboundConfirm} onOpenChange={setShowInboundConfirm}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg">
{v2Config ? "실행 확인" : "입고 확정"}
</AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
{v2Config
? (v2Config.confirm?.message || "작업을 실행하시겠습니까?")
: (config?.confirm?.message || "선택한 품목을 입고 확정하시겠습니까?")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel
disabled={confirmProcessing}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogCancel>
<AlertDialogAction
onClick={() => { v2Config ? handleV2Execute() : handleInboundConfirm(); }}
disabled={confirmProcessing}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{confirmProcessing ? "처리 중..." : "확인"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 채번 결과 다이얼로그 - 사용자가 확인 누를 때까지 유지 */}
<AlertDialog open={generatedCodesResult.length > 0} onOpenChange={(open) => { if (!open) handleCloseGeneratedCodesModal(); }}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg">
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2 text-xs sm:text-sm">
<p> .</p>
{generatedCodesResult.map((c, i) => (
<div key={i} className="flex items-center justify-between rounded-md border p-2">
<span className="text-muted-foreground">{c.targetColumn}</span>
<span className="font-mono font-semibold">{c.code}</span>
</div>
))}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction
onClick={handleCloseGeneratedCodesModal}
className="h-8 w-full text-xs sm:h-10 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: 설정 패널 (v2 작업 목록 기반)
// ========================================
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,
}: PopButtonConfigPanelProps) {
const v2 = useMemo(() => migrateButtonConfig(config), [config]);
const updateV2 = useCallback(
(partial: Partial<PopButtonConfigV2>) => {
const merged = { ...v2, ...partial };
onUpdate(merged as unknown as PopButtonConfig);
},
[v2, onUpdate],
);
const updateTask = useCallback(
(taskId: string, partial: Partial<ButtonTask>) => {
const next = v2.tasks.map((t) => (t.id === taskId ? { ...t, ...partial } : t));
updateV2({ tasks: next });
},
[v2.tasks, updateV2],
);
const removeTask = useCallback(
(taskId: string) => {
updateV2({ tasks: v2.tasks.filter((t) => t.id !== taskId) });
},
[v2.tasks, updateV2],
);
const addTask = useCallback(
(type: ButtonTaskType) => {
const id = `t${Date.now()}`;
updateV2({ tasks: [...v2.tasks, { id, type }] });
},
[v2.tasks, updateV2],
);
// 빠른 시작
const applyQuickStart = useCallback(
(template: QuickStartTemplate) => {
const defaults = QUICK_START_DEFAULTS[template];
updateV2({
label: defaults.label,
variant: defaults.variant,
icon: defaults.icon === "none" ? undefined : defaults.icon,
confirm: defaults.confirm,
tasks: defaults.tasks.map((t) => ({ ...t, id: `t${Date.now()}_${Math.random().toString(36).slice(2, 6)}` })),
});
},
[updateV2],
);
// 작업 순서 이동
const moveTask = useCallback(
(taskId: string, direction: "up" | "down") => {
const idx = v2.tasks.findIndex((t) => t.id === taskId);
if (idx < 0) return;
const swapIdx = direction === "up" ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= v2.tasks.length) return;
const next = [...v2.tasks];
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
updateV2({ tasks: next });
},
[v2.tasks, updateV2],
);
return (
<div className="space-y-2 overflow-y-auto pr-1 pb-32">
{/* 빠른 시작 */}
<SectionDivider label="빠른 시작" />
<div className="flex flex-wrap gap-1">
{(Object.keys(QUICK_START_DEFAULTS) as QuickStartTemplate[]).map((key) => (
<Button
key={key}
variant="outline"
size="sm"
className="h-7 text-[10px]"
onClick={() => applyQuickStart(key)}
>
{QUICK_START_DEFAULTS[key].label}
</Button>
))}
</div>
<p className="text-[10px] text-muted-foreground">
</p>
{/* 외형 설정 */}
<SectionDivider label="외형" />
<div className="space-y-2">
<div>
<Label className="text-xs"></Label>
<Input
value={v2.label || ""}
onChange={(e) => updateV2({ label: e.target.value })}
placeholder="버튼 텍스트"
className="h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Select
value={v2.variant || "default"}
onValueChange={(v) => updateV2({ variant: v as ButtonVariant })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(VARIANT_LABELS).map(([key, lbl]) => (
<SelectItem key={key} value={key} className="text-xs">
{lbl}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select
value={v2.icon || "none"}
onValueChange={(v) => updateV2({ icon: v === "none" ? undefined : 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="iconOnlyV2"
checked={v2.iconOnly || false}
onCheckedChange={(checked) => updateV2({ iconOnly: checked === true })}
/>
<Label htmlFor="iconOnlyV2" className="text-xs">
( )
</Label>
</div>
</div>
{/* 확인 메시지 */}
<SectionDivider label="확인 메시지" />
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="confirmEnabledV2"
checked={v2.confirm?.enabled || false}
onCheckedChange={(checked) =>
updateV2({ confirm: { ...v2.confirm, enabled: checked === true } })
}
/>
<Label htmlFor="confirmEnabledV2" className="text-xs">
</Label>
</div>
{v2.confirm?.enabled && (
<Input
value={v2.confirm?.message || ""}
onChange={(e) =>
updateV2({ confirm: { ...v2.confirm, enabled: true, message: e.target.value } })
}
placeholder="비워두면 기본 메시지 사용"
className="h-8 text-xs"
/>
)}
</div>
{/* 작업 목록 */}
<SectionDivider label="작업 목록" />
<div className="space-y-1.5">
{v2.tasks.length === 0 && (
<p className="text-[10px] text-muted-foreground py-2 text-center">
. .
</p>
)}
{v2.tasks.map((task, idx) => (
<TaskItemEditor
key={task.id}
task={task}
index={idx}
totalCount={v2.tasks.length}
onUpdate={(partial) => updateTask(task.id, partial)}
onRemove={() => removeTask(task.id)}
onMove={(dir) => moveTask(task.id, dir)}
/>
))}
{/* 작업 추가 */}
<Select onValueChange={(v) => addTask(v as ButtonTaskType)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="+ 작업 추가" />
</SelectTrigger>
<SelectContent>
{(Object.entries(TASK_TYPE_LABELS) as [ButtonTaskType, string][]).map(([key, lbl]) => (
<SelectItem key={key} value={key} className="text-xs">
{lbl}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
// ========================================
// 작업 항목 에디터 (접힘/펼침)
// ========================================
function TaskItemEditor({
task,
index,
totalCount,
onUpdate,
onRemove,
onMove,
}: {
task: ButtonTask;
index: number;
totalCount: number;
onUpdate: (partial: Partial<ButtonTask>) => void;
onRemove: () => void;
onMove: (direction: "up" | "down") => void;
}) {
const [expanded, setExpanded] = useState(false);
const designerCtx = usePopDesignerContext();
return (
<div className="rounded border border-border">
{/* 헤더: 타입 + 순서 + 삭제 */}
<div
className="flex cursor-pointer items-center gap-1 px-2 py-1.5 hover:bg-muted/30"
onClick={() => setExpanded(!expanded)}
>
<ChevronRight
className={cn("h-3 w-3 shrink-0 transition-transform", expanded && "rotate-90")}
/>
<GripVertical className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="text-[10px] font-medium">
{index + 1}. {TASK_TYPE_LABELS[task.type]}
</span>
{task.label && (
<span className="ml-1 text-[10px] text-muted-foreground truncate">
({task.label})
</span>
)}
<div className="ml-auto flex items-center gap-0.5">
{index > 0 && (
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={(e) => { e.stopPropagation(); onMove("up"); }}>
<span className="text-[10px]">^</span>
</Button>
)}
{index < totalCount - 1 && (
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={(e) => { e.stopPropagation(); onMove("down"); }}>
<span className="text-[10px]">v</span>
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-destructive"
onClick={(e) => { e.stopPropagation(); onRemove(); }}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{/* 펼침: 타입별 설정 폼 */}
{expanded && (
<div className="space-y-2 border-t px-2 py-2">
<TaskDetailForm task={task} onUpdate={onUpdate} designerCtx={designerCtx} />
</div>
)}
</div>
);
}
// ========================================
// 작업별 설정 폼 (M-4)
// ========================================
function TaskDetailForm({
task,
onUpdate,
designerCtx,
}: {
task: ButtonTask;
onUpdate: (partial: Partial<ButtonTask>) => void;
designerCtx: ReturnType<typeof usePopDesignerContext>;
}) {
// 테이블/컬럼 조회 (data-update, data-delete용)
const [tables, setTables] = useState<TableInfo[]>([]);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const needsTable = task.type === "data-update" || task.type === "data-delete";
useEffect(() => {
if (needsTable) fetchTableList().then(setTables);
}, [needsTable]);
useEffect(() => {
if (needsTable && task.targetTable) {
fetchTableColumns(task.targetTable).then(setColumns);
} else {
setColumns([]);
}
}, [needsTable, task.targetTable]);
switch (task.type) {
case "data-save":
return (
<p className="text-[10px] text-muted-foreground">
. .
</p>
);
case "data-update":
return (
<DataUpdateTaskForm
task={task}
onUpdate={onUpdate}
tables={tables}
columns={columns}
/>
);
case "data-delete":
return (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<TableCombobox
tables={tables}
value={task.targetTable || ""}
onSelect={(v) => onUpdate({ targetTable: v })}
/>
</div>
);
case "cart-save":
return (
<div className="space-y-1">
<Label className="text-[10px]"> ID ( )</Label>
<Input
value={task.cartScreenId || ""}
onChange={(e) => onUpdate({ cartScreenId: e.target.value })}
placeholder="비워두면 이동 없이 저장만"
className="h-7 text-xs"
/>
</div>
);
case "modal-open":
return (
<div className="space-y-2">
<div>
<Label className="text-[10px]"> </Label>
<Select
value={task.modalMode || "fullscreen"}
onValueChange={(v) => onUpdate({ modalMode: v as ModalMode })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(MODAL_MODE_LABELS).map(([key, lbl]) => (
<SelectItem key={key} value={key} className="text-xs">{lbl}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{task.modalMode === "screen-ref" && (
<div>
<Label className="text-[10px]"> ID</Label>
<Input
value={task.modalScreenId || ""}
onChange={(e) => onUpdate({ modalScreenId: e.target.value })}
placeholder="화면 ID"
className="h-7 text-xs"
/>
</div>
)}
<div>
<Label className="text-[10px]"> </Label>
<Input
value={task.modalTitle || ""}
onChange={(e) => onUpdate({ modalTitle: e.target.value })}
placeholder="모달 제목 (선택)"
className="h-7 text-xs"
/>
</div>
{task.modalMode === "fullscreen" && designerCtx && (
<div>
{task.modalScreenId ? (
<Button variant="outline" size="sm" className="h-7 w-full text-xs" onClick={() => designerCtx.navigateToCanvas(task.modalScreenId!)}>
</Button>
) : (
<Button
variant="outline"
size="sm"
className="h-7 w-full text-xs"
onClick={() => {
const selectedId = designerCtx.selectedComponentId;
if (!selectedId) return;
const modalId = designerCtx.createModalCanvas(selectedId, task.modalTitle || "새 모달");
onUpdate({ modalScreenId: modalId });
}}
>
</Button>
)}
</div>
)}
</div>
);
case "navigate":
return (
<div className="space-y-1">
<Label className="text-[10px]"> ID</Label>
<Input
value={task.targetScreenId || ""}
onChange={(e) => onUpdate({ targetScreenId: e.target.value })}
placeholder="이동할 화면 ID"
className="h-7 text-xs"
/>
</div>
);
case "api-call":
return (
<div className="space-y-2">
<div>
<Label className="text-[10px]"></Label>
<Input
value={task.apiEndpoint || ""}
onChange={(e) => onUpdate({ apiEndpoint: e.target.value })}
placeholder="/api/..."
className="h-7 text-xs"
/>
</div>
<div>
<Label className="text-[10px]">HTTP </Label>
<Select
value={task.apiMethod || "POST"}
onValueChange={(v) => onUpdate({ apiMethod: v as "GET" | "POST" | "PUT" | "DELETE" })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(API_METHOD_LABELS).map(([key, lbl]) => (
<SelectItem key={key} value={key} className="text-xs">{lbl}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
case "custom-event":
return (
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
value={task.eventName || ""}
onChange={(e) => onUpdate({ eventName: e.target.value })}
placeholder="예: data-saved, item-selected"
className="h-7 text-xs"
/>
</div>
);
case "refresh":
case "close-modal":
return (
<p className="text-[10px] text-muted-foreground"> </p>
);
default:
return null;
}
}
// ========================================
// 데이터 수정 작업 폼 (data-update 전용)
// ========================================
function DataUpdateTaskForm({
task,
onUpdate,
tables,
columns,
}: {
task: ButtonTask;
onUpdate: (partial: Partial<ButtonTask>) => void;
tables: TableInfo[];
columns: ColumnInfo[];
}) {
const conditions = task.conditionalValue?.conditions ?? [];
const defaultValue = task.conditionalValue?.defaultValue ?? "";
const updateCondition = (cIdx: number, partial: Partial<(typeof conditions)[0]>) => {
const next = [...conditions];
next[cIdx] = { ...next[cIdx], ...partial };
onUpdate({ conditionalValue: { conditions: next, defaultValue } });
};
const removeCondition = (cIdx: number) => {
const next = [...conditions];
next.splice(cIdx, 1);
onUpdate({ conditionalValue: { conditions: next, defaultValue } });
};
const addCondition = () => {
onUpdate({
conditionalValue: {
conditions: [...conditions, { whenColumn: "", operator: "=" as const, whenValue: "", thenValue: "" }],
defaultValue,
},
});
};
return (
<div className="space-y-2">
{/* 대상 테이블 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<TableCombobox
tables={tables}
value={task.targetTable || ""}
onSelect={(v) => onUpdate({ targetTable: v, targetColumn: "" })}
/>
</div>
{/* 변경 컬럼 */}
{task.targetTable && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<ColumnCombobox
columns={columns}
value={task.targetColumn || ""}
onSelect={(v) => onUpdate({ targetColumn: v })}
/>
</div>
)}
{/* 연산 타입 */}
{task.targetColumn && (
<>
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Select
value={task.operationType || "assign"}
onValueChange={(v) => onUpdate({ operationType: v as UpdateOperationType })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="assign" className="text-xs"> (=)</SelectItem>
<SelectItem value="add" className="text-xs"> (+=)</SelectItem>
<SelectItem value="subtract" className="text-xs"> (-=)</SelectItem>
<SelectItem value="multiply" className="text-xs"> (*=)</SelectItem>
<SelectItem value="divide" className="text-xs"> (/=)</SelectItem>
<SelectItem value="conditional" className="text-xs"> ()</SelectItem>
<SelectItem value="db-conditional" className="text-xs"> (DB )</SelectItem>
</SelectContent>
</Select>
</div>
{/* 값 출처 (conditional/db-conditional이 아닐 때) */}
{task.operationType !== "conditional" && task.operationType !== "db-conditional" && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={task.valueSource || "fixed"}
onValueChange={(v) => onUpdate({ valueSource: v as UpdateValueSource })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fixed" className="text-xs"></SelectItem>
<SelectItem value="linked" className="text-xs"> </SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* 고정값 입력 */}
{task.valueSource === "fixed" && task.operationType !== "conditional" && task.operationType !== "db-conditional" && (
<Input
value={task.fixedValue || ""}
onChange={(e) => onUpdate({ fixedValue: e.target.value })}
className="h-7 text-xs"
placeholder="변경할 값"
/>
)}
{/* 연결 데이터 필드명 */}
{task.valueSource === "linked" && task.operationType !== "conditional" && task.operationType !== "db-conditional" && (
<Input
value={task.sourceField || ""}
onChange={(e) => onUpdate({ sourceField: e.target.value })}
className="h-7 text-xs"
placeholder="연결 필드명 (예: qty)"
/>
)}
{/* DB 컬럼 비교 조건부 설정 */}
{task.operationType === "db-conditional" && (
<div className="space-y-2 rounded border border-dashed border-muted-foreground/30 p-2">
<p className="text-[10px] text-muted-foreground">DB에서 A와 B를 </p>
<div className="flex items-center gap-1">
<ColumnCombobox columns={columns} value={task.compareColumn ?? ""} onSelect={(v) => onUpdate({ compareColumn: v })} placeholder="비교 컬럼 A" />
<Select value={task.compareOperator ?? ">="} onValueChange={(v) => onUpdate({ compareOperator: v as ButtonTask["compareOperator"] })}>
<SelectTrigger className="h-7 w-14 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
{["=", "!=", ">", "<", ">=", "<="].map((op) => (
<SelectItem key={op} value={op} className="text-xs">{op}</SelectItem>
))}
</SelectContent>
</Select>
<ColumnCombobox columns={columns} value={task.compareWith ?? ""} onSelect={(v) => onUpdate({ compareWith: v })} placeholder="비교 컬럼 B" />
</div>
<div className="flex items-center gap-1">
<span className="shrink-0 text-[10px] text-muted-foreground"> -&gt;</span>
<Input value={task.dbThenValue ?? ""} onChange={(e) => onUpdate({ dbThenValue: e.target.value })} className="h-7 flex-1 text-[10px]" placeholder="예: 입고완료" />
</div>
<div className="flex items-center gap-1">
<span className="shrink-0 text-[10px] text-muted-foreground"> -&gt;</span>
<Input value={task.dbElseValue ?? ""} onChange={(e) => onUpdate({ dbElseValue: e.target.value })} className="h-7 flex-1 text-[10px]" placeholder="예: 부분입고" />
</div>
</div>
)}
{/* 조건부 값 설정 */}
{task.operationType === "conditional" && (
<div className="space-y-2 rounded border border-dashed border-muted-foreground/30 p-2">
{conditions.map((cond, cIdx) => (
<div key={cIdx} className="space-y-1">
<div className="flex items-center gap-1">
<span className="shrink-0 text-[10px] text-muted-foreground"></span>
<ColumnCombobox columns={columns} value={cond.whenColumn} onSelect={(v) => updateCondition(cIdx, { whenColumn: v })} placeholder="컬럼" />
<Select value={cond.operator} onValueChange={(v) => updateCondition(cIdx, { operator: v as "=" | "!=" | ">" | "<" | ">=" | "<=" })}>
<SelectTrigger className="h-7 w-14 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
{["=", "!=", ">", "<", ">=", "<="].map((op) => (
<SelectItem key={op} value={op} className="text-xs">{op}</SelectItem>
))}
</SelectContent>
</Select>
<Input value={cond.whenValue} onChange={(e) => updateCondition(cIdx, { whenValue: e.target.value })} className="h-7 w-16 text-[10px]" placeholder="값" />
<Button variant="ghost" size="icon" className="h-5 w-5 shrink-0" onClick={() => removeCondition(cIdx)}>
<X className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center gap-1 pl-4">
<span className="shrink-0 text-[10px] text-muted-foreground"> -&gt;</span>
<Input value={cond.thenValue} onChange={(e) => updateCondition(cIdx, { thenValue: e.target.value })} className="h-7 text-[10px]" placeholder="변경할 값" />
</div>
</div>
))}
<Button variant="ghost" size="sm" className="w-full text-[10px]" onClick={addCondition}>
<Plus className="mr-1 h-3 w-3" />
</Button>
<div className="flex items-center gap-1">
<span className="shrink-0 text-[10px] text-muted-foreground"> -&gt;</span>
<Input
value={defaultValue}
onChange={(e) => onUpdate({ conditionalValue: { conditions, defaultValue: e.target.value } })}
className="h-7 text-[10px]"
placeholder="기본값"
/>
</div>
</div>
)}
{/* 조회 키 */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<Label className="text-[10px]"> </Label>
<Select
value={task.lookupMode ?? "auto"}
onValueChange={(v) => onUpdate({ lookupMode: v as "auto" | "manual" })}
>
<SelectTrigger className="h-6 w-16 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="auto" className="text-[10px]"></SelectItem>
<SelectItem value="manual" className="text-[10px]"></SelectItem>
</SelectContent>
</Select>
</div>
{task.lookupMode === "manual" && (
<div className="flex items-center gap-1">
<Select value={task.manualItemField ?? ""} onValueChange={(v) => onUpdate({ manualItemField: v })}>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="카드 항목 필드" /></SelectTrigger>
<SelectContent>
{KNOWN_ITEM_FIELDS.map((f) => (
<SelectItem key={f.value} value={f.value} className="text-[10px]">{f.label}</SelectItem>
))}
</SelectContent>
</Select>
<span className="shrink-0 text-[10px] text-muted-foreground">-&gt;</span>
<ColumnCombobox columns={columns} value={task.manualPkColumn ?? ""} onSelect={(v) => onUpdate({ manualPkColumn: v })} placeholder="대상 PK 컬럼" />
</div>
)}
</div>
</>
)}
</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>
);
}
// ========================================
// 상태 변경 규칙 편집기
// ========================================
const KNOWN_ITEM_FIELDS = [
{ value: "__cart_id", label: "__cart_id (카드 항목 ID)" },
{ value: "__cart_row_key", label: "__cart_row_key (원본 PK 값)" },
{ value: "id", label: "id" },
{ value: "row_key", label: "row_key" },
];
function StatusChangeRuleEditor({
rules,
onUpdate,
}: {
rules: StatusChangeRule[];
onUpdate: (rules: StatusChangeRule[]) => void;
}) {
const [tables, setTables] = useState<TableInfo[]>([]);
const [columnsMap, setColumnsMap] = useState<Record<string, ColumnInfo[]>>({});
useEffect(() => {
fetchTableList().then(setTables);
}, []);
const loadColumns = (tableName: string) => {
if (!tableName || columnsMap[tableName]) return;
fetchTableColumns(tableName).then((cols) => {
setColumnsMap((prev) => ({ ...prev, [tableName]: cols }));
});
};
const updateRule = (idx: number, partial: Partial<StatusChangeRule>) => {
const next = [...rules];
next[idx] = { ...next[idx], ...partial };
onUpdate(next);
};
const removeRule = (idx: number) => {
const next = [...rules];
next.splice(idx, 1);
onUpdate(next);
};
const addRule = () => {
onUpdate([
...rules,
{
targetTable: "",
targetColumn: "",
valueType: "fixed",
fixedValue: "",
},
]);
};
return (
<div className="space-y-2 px-1">
{rules.map((rule, idx) => (
<SingleRuleEditor
key={idx}
rule={rule}
idx={idx}
tables={tables}
columns={columnsMap[rule.targetTable] ?? []}
onLoadColumns={loadColumns}
onUpdate={(partial) => updateRule(idx, partial)}
onRemove={() => removeRule(idx)}
/>
))}
<Button variant="outline" size="sm" className="w-full text-xs" onClick={addRule}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
);
}
function SingleRuleEditor({
rule,
idx,
tables,
columns,
onLoadColumns,
onUpdate,
onRemove,
}: {
rule: StatusChangeRule;
idx: number;
tables: TableInfo[];
columns: ColumnInfo[];
onLoadColumns: (tableName: string) => void;
onUpdate: (partial: Partial<StatusChangeRule>) => void;
onRemove: () => void;
}) {
useEffect(() => {
if (rule.targetTable) onLoadColumns(rule.targetTable);
}, [rule.targetTable]); // eslint-disable-line react-hooks/exhaustive-deps
const conditions = rule.conditionalValue?.conditions ?? [];
const defaultValue = rule.conditionalValue?.defaultValue ?? "";
const updateCondition = (cIdx: number, partial: Partial<(typeof conditions)[0]>) => {
const next = [...conditions];
next[cIdx] = { ...next[cIdx], ...partial };
onUpdate({
conditionalValue: { ...rule.conditionalValue, conditions: next, defaultValue },
});
};
const removeCondition = (cIdx: number) => {
const next = [...conditions];
next.splice(cIdx, 1);
onUpdate({
conditionalValue: { ...rule.conditionalValue, conditions: next, defaultValue },
});
};
const addCondition = () => {
onUpdate({
conditionalValue: {
...rule.conditionalValue,
conditions: [...conditions, { whenColumn: "", operator: "=" as const, whenValue: "", thenValue: "" }],
defaultValue,
},
});
};
return (
<div className="space-y-2 rounded border border-border p-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium text-muted-foreground"> {idx + 1}</span>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onRemove}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 대상 테이블 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<TableCombobox
tables={tables}
value={rule.targetTable}
onSelect={(v) => onUpdate({ targetTable: v, targetColumn: "" })}
/>
</div>
{/* 변경 컬럼 */}
{rule.targetTable && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<ColumnCombobox
columns={columns}
value={rule.targetColumn}
onSelect={(v) => onUpdate({ targetColumn: v })}
/>
</div>
)}
{/* 조회 키 */}
{rule.targetColumn && (
<div className="space-y-1">
<div className="flex items-center gap-2">
<Label className="text-[10px]"> </Label>
<Select
value={rule.lookupMode ?? "auto"}
onValueChange={(v) => onUpdate({ lookupMode: v as "auto" | "manual" })}
>
<SelectTrigger className="h-6 w-16 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto" className="text-[10px]"></SelectItem>
<SelectItem value="manual" className="text-[10px]"></SelectItem>
</SelectContent>
</Select>
</div>
{(rule.lookupMode ?? "auto") === "auto" ? (
<p className="text-[10px] text-muted-foreground">
{rule.targetTable === "cart_items"
? `카드 항목.__cart_id → ${rule.targetTable}.id`
: `카드 항목.row_key → ${rule.targetTable}.${columns.find(c => c.isPrimaryKey)?.name ?? "PK(조회중)"}`}
</p>
) : (
<div className="flex items-center gap-1">
<Select
value={rule.manualItemField ?? ""}
onValueChange={(v) => onUpdate({ manualItemField: v })}
>
<SelectTrigger className="h-7 flex-1 text-[10px]">
<SelectValue placeholder="카드 항목 필드" />
</SelectTrigger>
<SelectContent>
{KNOWN_ITEM_FIELDS.map((f) => (
<SelectItem key={f.value} value={f.value} className="text-[10px]">{f.label}</SelectItem>
))}
</SelectContent>
</Select>
<span className="shrink-0 text-[10px] text-muted-foreground"></span>
<ColumnCombobox
columns={columns}
value={rule.manualPkColumn ?? ""}
onSelect={(v) => onUpdate({ manualPkColumn: v })}
placeholder="대상 PK 컬럼"
/>
</div>
)}
</div>
)}
{/* 변경 값 타입 */}
{rule.targetColumn && (
<>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1 text-[10px]">
<input
type="radio"
name={`valueType-${idx}`}
checked={rule.valueType === "fixed"}
onChange={() => onUpdate({ valueType: "fixed" })}
className="h-3 w-3"
/>
</label>
<label className="flex items-center gap-1 text-[10px]">
<input
type="radio"
name={`valueType-${idx}`}
checked={rule.valueType === "conditional"}
onChange={() => onUpdate({ valueType: "conditional" })}
className="h-3 w-3"
/>
</label>
</div>
</div>
{/* 고정값 */}
{rule.valueType === "fixed" && (
<div className="space-y-1">
<Input
value={rule.fixedValue ?? ""}
onChange={(e) => onUpdate({ fixedValue: e.target.value })}
className="h-7 text-xs"
placeholder="변경할 값 입력"
/>
</div>
)}
{/* 조건부 */}
{rule.valueType === "conditional" && (
<div className="space-y-2 rounded border border-dashed border-muted-foreground/30 p-2">
{conditions.map((cond, cIdx) => (
<div key={cIdx} className="space-y-1">
<div className="flex items-center gap-1">
<span className="shrink-0 text-[10px] text-muted-foreground"></span>
<ColumnCombobox
columns={columns}
value={cond.whenColumn}
onSelect={(v) => updateCondition(cIdx, { whenColumn: v })}
placeholder="컬럼"
/>
<Select
value={cond.operator}
onValueChange={(v) => updateCondition(cIdx, { operator: v as "=" | "!=" | ">" | "<" | ">=" | "<=" })}
>
<SelectTrigger className="h-7 w-14 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{["=", "!=", ">", "<", ">=", "<="].map((op) => (
<SelectItem key={op} value={op} className="text-xs">{op}</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={cond.whenValue}
onChange={(e) => updateCondition(cIdx, { whenValue: e.target.value })}
className="h-7 w-16 text-[10px]"
placeholder="값"
/>
<Button variant="ghost" size="icon" className="h-5 w-5 shrink-0" onClick={() => removeCondition(cIdx)}>
<X className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center gap-1 pl-4">
<span className="shrink-0 text-[10px] text-muted-foreground"> -&gt;</span>
<Input
value={cond.thenValue}
onChange={(e) => updateCondition(cIdx, { thenValue: e.target.value })}
className="h-7 text-[10px]"
placeholder="변경할 값"
/>
</div>
</div>
))}
<Button variant="ghost" size="sm" className="w-full text-[10px]" onClick={addCondition}>
<Plus className="mr-1 h-3 w-3" />
</Button>
<div className="flex items-center gap-1">
<span className="shrink-0 text-[10px] text-muted-foreground"> -&gt;</span>
<Input
value={defaultValue}
onChange={(e) =>
onUpdate({
conditionalValue: { ...rule.conditionalValue, conditions, defaultValue: e.target.value },
})
}
className="h-7 text-[10px]"
placeholder="기본값"
/>
</div>
</div>
)}
</>
)}
</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", category: "event", description: "장바구니 일괄 저장 요청 (카드 목록에 전달)" },
{ key: "collect_data", label: "데이터 수집 요청", type: "event", category: "event", description: "연결된 컴포넌트에 데이터+매핑 수집 요청" },
{ key: "action_completed", label: "액션 완료", type: "event", category: "event", description: "확정/저장 완료 후 결과 전달" },
],
receivable: [
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" },
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "카드 목록에서 DB 저장 완료 시 수신" },
{ key: "collected_data", label: "수집된 데이터", type: "event", category: "event", description: "컴포넌트에서 수집한 데이터+매핑 응답" },
{ key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "카드 목록에서 체크박스로 선택된 항목 수 수신" },
],
},
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});