"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; // navigate targetScreenId?: string; params?: Record; } /** 드롭다운 모달 메뉴 항목 */ 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; } /** 프리셋 이름 */ 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; // api-call apiEndpoint?: string; apiMethod?: "GET" | "POST" | "PUT" | "DELETE"; // custom-event eventName?: string; eventPayload?: Record; } /** 빠른 시작 템플릿 */ 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 = { "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 = { 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 = { save: "저장", delete: "삭제", api: "API 호출", modal: "모달 열기", event: "이벤트 발행", }; /** 후속 액션 타입 라벨 */ const FOLLOWUP_TYPE_LABELS: Record = { event: "이벤트 발행", refresh: "새로고침", navigate: "화면 이동", "close-modal": "모달 닫기", }; /** variant 라벨 */ const VARIANT_LABELS: Record = { default: "기본 (Primary)", secondary: "보조 (Secondary)", outline: "외곽선 (Outline)", destructive: "위험 (Destructive)", }; /** 프리셋 라벨 */ const PRESET_LABELS: Record = { save: "저장", delete: "삭제", logout: "로그아웃", menu: "메뉴 (드롭다운)", "modal-open": "모달 열기", cart: "장바구니 저장", "inbound-confirm": "입고 확정", custom: "직접 설정", }; /** 모달 모드 라벨 */ const MODAL_MODE_LABELS: Record = { dropdown: "드롭다운", fullscreen: "전체 모달", "screen-ref": "화면 선택", }; /** API 메서드 라벨 */ const API_METHOD_LABELS: Record = { 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> = { 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 = { save: "저장하시겠습니까?", delete: "삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", api: "실행하시겠습니까?", modal: "열기하시겠습니까?", event: "실행하시겠습니까?", }; // ======================================== // 헬퍼 함수 // ======================================== /** 섹션 구분선 */ function SectionDivider({ label }: { label: string }) { return (
{label}
); } /** 장바구니 데이터 매핑 행 (읽기 전용) */ function CartMappingRow({ source, target, desc, auto, }: { source: string; target: string; desc?: string; auto?: boolean; }) { return (
{source}
{target} {desc && (

{desc}

)}
); } /** 허용된 아이콘 맵 (개별 import로 트리 쉐이킹 적용) */ const LUCIDE_ICON_MAP: Record = { 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 ; } // ======================================== // 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>([]); const [deferredV2Tasks, setDeferredV2Tasks] = useState }>>([]); 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 | 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)?.cartChanges as CollectedPayload["cartChanges"], }; const result = await executeTaskList(v2Config.tasks, { publish, componentId, collectedData, }); if (result.success) { const resultData = result.data as Record | 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 }> | 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 ( <>
{/* 장바구니 배지 */} {isCartMode && cartCount > 0 && (
{cartCount}
)} {/* 선택 개수 배지 (v1 inbound-confirm + v2 data tasks) */} {!isCartMode && hasDataTasks && inboundSelectedCount > 0 && (
{inboundSelectedCount}
)}
{/* 장바구니 확인 다이얼로그 */} 장바구니 저장 {config?.confirm?.message || `${cartCount}개 항목을 장바구니에 저장하시겠습니까?`} 취소 { setShowCartConfirm(false); handleCartSave(); }} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" > 저장 {/* 확정/실행 확인 다이얼로그 (v2 + v1 입고확정 공용) */} {v2Config ? "실행 확인" : "입고 확정"} {v2Config ? (v2Config.confirm?.message || "작업을 실행하시겠습니까?") : (config?.confirm?.message || "선택한 품목을 입고 확정하시겠습니까?")} 취소 { v2Config ? handleV2Execute() : handleInboundConfirm(); }} disabled={confirmProcessing} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" > {confirmProcessing ? "처리 중..." : "확인"} {/* 채번 결과 다이얼로그 - 사용자가 확인 누를 때까지 유지 */} 0} onOpenChange={(open) => { if (!open) handleCloseGeneratedCodesModal(); }}> 자동 생성 완료

다음 번호가 자동 생성되었습니다.

{generatedCodesResult.map((c, i) => (
{c.targetColumn} {c.code}
))}
확인
{/* 일반 확인 다이얼로그 */} { if (!open) cancelConfirm(); }}> 실행 확인 {getConfirmMessage()} 취소 확인 ); } // ======================================== // STEP 3: 설정 패널 (v2 작업 목록 기반) // ======================================== interface PopButtonConfigPanelProps { config: PopButtonConfig; onUpdate: (config: PopButtonConfig) => void; allComponents?: { id: string; type: string; config?: Record }[]; 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) => { const merged = { ...v2, ...partial }; onUpdate(merged as unknown as PopButtonConfig); }, [v2, onUpdate], ); const updateTask = useCallback( (taskId: string, partial: Partial) => { 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 (
{/* 빠른 시작 */}
{(Object.keys(QUICK_START_DEFAULTS) as QuickStartTemplate[]).map((key) => ( ))}

클릭하면 외형과 작업 목록이 자동 설정됩니다

{/* 외형 설정 */}
updateV2({ label: e.target.value })} placeholder="버튼 텍스트" className="h-8 text-xs" />
updateV2({ iconOnly: checked === true })} />
{/* 확인 메시지 */}
updateV2({ confirm: { ...v2.confirm, enabled: checked === true } }) } />
{v2.confirm?.enabled && ( updateV2({ confirm: { ...v2.confirm, enabled: true, message: e.target.value } }) } placeholder="비워두면 기본 메시지 사용" className="h-8 text-xs" /> )}
{/* 작업 목록 */}
{v2.tasks.length === 0 && (

작업이 없습니다. 빠른 시작 또는 아래 버튼으로 추가하세요.

)} {v2.tasks.map((task, idx) => ( updateTask(task.id, partial)} onRemove={() => removeTask(task.id)} onMove={(dir) => moveTask(task.id, dir)} /> ))} {/* 작업 추가 */}
); } // ======================================== // 작업 항목 에디터 (접힘/펼침) // ======================================== function TaskItemEditor({ task, index, totalCount, onUpdate, onRemove, onMove, }: { task: ButtonTask; index: number; totalCount: number; onUpdate: (partial: Partial) => void; onRemove: () => void; onMove: (direction: "up" | "down") => void; }) { const [expanded, setExpanded] = useState(false); const designerCtx = usePopDesignerContext(); return (
{/* 헤더: 타입 + 순서 + 삭제 */}
setExpanded(!expanded)} > {index + 1}. {TASK_TYPE_LABELS[task.type]} {task.label && ( ({task.label}) )}
{index > 0 && ( )} {index < totalCount - 1 && ( )}
{/* 펼침: 타입별 설정 폼 */} {expanded && (
)}
); } // ======================================== // 작업별 설정 폼 (M-4) // ======================================== function TaskDetailForm({ task, onUpdate, designerCtx, }: { task: ButtonTask; onUpdate: (partial: Partial) => void; designerCtx: ReturnType; }) { // 테이블/컬럼 조회 (data-update, data-delete용) const [tables, setTables] = useState([]); const [columns, setColumns] = useState([]); 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 (

연결된 입력 컴포넌트의 저장 매핑을 사용합니다. 별도 설정 불필요.

); case "data-update": return ( ); case "data-delete": return (
onUpdate({ targetTable: v })} />
); case "cart-save": return (
onUpdate({ cartScreenId: e.target.value })} placeholder="비워두면 이동 없이 저장만" className="h-7 text-xs" />
); case "modal-open": return (
{task.modalMode === "screen-ref" && (
onUpdate({ modalScreenId: e.target.value })} placeholder="화면 ID" className="h-7 text-xs" />
)}
onUpdate({ modalTitle: e.target.value })} placeholder="모달 제목 (선택)" className="h-7 text-xs" />
{task.modalMode === "fullscreen" && designerCtx && (
{task.modalScreenId ? ( ) : ( )}
)}
); case "navigate": return (
onUpdate({ targetScreenId: e.target.value })} placeholder="이동할 화면 ID" className="h-7 text-xs" />
); case "api-call": return (
onUpdate({ apiEndpoint: e.target.value })} placeholder="/api/..." className="h-7 text-xs" />
); case "custom-event": return (
onUpdate({ eventName: e.target.value })} placeholder="예: data-saved, item-selected" className="h-7 text-xs" />
); case "refresh": case "close-modal": return (

설정 불필요

); default: return null; } } // ======================================== // 데이터 수정 작업 폼 (data-update 전용) // ======================================== function DataUpdateTaskForm({ task, onUpdate, tables, columns, }: { task: ButtonTask; onUpdate: (partial: Partial) => 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 (
{/* 대상 테이블 */}
onUpdate({ targetTable: v, targetColumn: "" })} />
{/* 변경 컬럼 */} {task.targetTable && (
onUpdate({ targetColumn: v })} />
)} {/* 연산 타입 */} {task.targetColumn && ( <>
{/* 값 출처 (conditional/db-conditional이 아닐 때) */} {task.operationType !== "conditional" && task.operationType !== "db-conditional" && (
)} {/* 고정값 입력 */} {task.valueSource === "fixed" && task.operationType !== "conditional" && task.operationType !== "db-conditional" && ( onUpdate({ fixedValue: e.target.value })} className="h-7 text-xs" placeholder="변경할 값" /> )} {/* 연결 데이터 필드명 */} {task.valueSource === "linked" && task.operationType !== "conditional" && task.operationType !== "db-conditional" && ( onUpdate({ sourceField: e.target.value })} className="h-7 text-xs" placeholder="연결 필드명 (예: qty)" /> )} {/* DB 컬럼 비교 조건부 설정 */} {task.operationType === "db-conditional" && (

DB에서 컬럼 A와 컬럼 B를 비교하여 값을 판정합니다

onUpdate({ compareColumn: v })} placeholder="비교 컬럼 A" /> onUpdate({ compareWith: v })} placeholder="비교 컬럼 B" />
참 -> onUpdate({ dbThenValue: e.target.value })} className="h-7 flex-1 text-[10px]" placeholder="예: 입고완료" />
거짓 -> onUpdate({ dbElseValue: e.target.value })} className="h-7 flex-1 text-[10px]" placeholder="예: 부분입고" />
)} {/* 조건부 값 설정 */} {task.operationType === "conditional" && (
{conditions.map((cond, cIdx) => (
만약 updateCondition(cIdx, { whenColumn: v })} placeholder="컬럼" /> updateCondition(cIdx, { whenValue: e.target.value })} className="h-7 w-16 text-[10px]" placeholder="값" />
이면 -> updateCondition(cIdx, { thenValue: e.target.value })} className="h-7 text-[10px]" placeholder="변경할 값" />
))}
그 외 -> onUpdate({ conditionalValue: { conditions, defaultValue: e.target.value } })} className="h-7 text-[10px]" placeholder="기본값" />
)} {/* 조회 키 */}
{task.lookupMode === "manual" && (
-> onUpdate({ manualPkColumn: v })} placeholder="대상 PK 컬럼" />
)}
)}
); } // ======================================== // 액션 세부 필드 (타입별) // ======================================== function ActionDetailFields({ action, onUpdate, disabled, }: { action?: ButtonMainAction; onUpdate: (updates: Partial) => void; disabled?: boolean; }) { // 디자이너 컨텍스트 (뷰어에서는 null) const designerCtx = usePopDesignerContext(); const actionType = action?.type || "save"; switch (actionType) { case "save": case "delete": return (
onUpdate({ targetTable: e.target.value })} placeholder="테이블명 입력" className="h-8 text-xs" disabled={disabled} />
); case "api": return (
onUpdate({ apiEndpoint: e.target.value })} placeholder="/api/..." className="h-8 text-xs" disabled={disabled} />
); case "modal": return (
{action?.modalMode === "screen-ref" && (
onUpdate({ modalScreenId: e.target.value }) } placeholder="화면 ID" className="h-8 text-xs" disabled={disabled} />
)}
onUpdate({ modalTitle: e.target.value })} placeholder="모달 제목 (선택)" className="h-8 text-xs" disabled={disabled} />
{/* 모달 캔버스 생성/열기 (fullscreen 모드 + 디자이너 내부) */} {action?.modalMode === "fullscreen" && designerCtx && (
{action?.modalScreenId ? ( ) : ( )}
)}
); case "event": return (
onUpdate({ eventName: e.target.value })} placeholder="예: data-saved, item-selected" className="h-8 text-xs" disabled={disabled} />
); 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) => { const newActions = [...actions]; newActions[index] = { ...newActions[index], ...updates }; onUpdate(newActions); }; return (
{actions.length === 0 && (

메인 액션 성공 후 순차 실행할 후속 동작

)} {actions.map((fa, idx) => (
후속 {idx + 1}
{/* 타입 선택 */} {/* 타입별 추가 입력 */} {fa.type === "event" && ( handleUpdate(idx, { eventName: e.target.value }) } placeholder="이벤트명" className="h-7 text-xs" /> )} {fa.type === "navigate" && ( handleUpdate(idx, { targetScreenId: e.target.value }) } placeholder="화면 ID" className="h-7 text-xs" /> )}
))}
); } // ======================================== // 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 (
); } // ======================================== // 상태 변경 규칙 편집기 // ======================================== 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([]); const [columnsMap, setColumnsMap] = useState>({}); 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) => { 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 (
{rules.map((rule, idx) => ( updateRule(idx, partial)} onRemove={() => removeRule(idx)} /> ))}
); } function SingleRuleEditor({ rule, idx, tables, columns, onLoadColumns, onUpdate, onRemove, }: { rule: StatusChangeRule; idx: number; tables: TableInfo[]; columns: ColumnInfo[]; onLoadColumns: (tableName: string) => void; onUpdate: (partial: Partial) => 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 (
규칙 {idx + 1}
{/* 대상 테이블 */}
onUpdate({ targetTable: v, targetColumn: "" })} />
{/* 변경 컬럼 */} {rule.targetTable && (
onUpdate({ targetColumn: v })} />
)} {/* 조회 키 */} {rule.targetColumn && (
{(rule.lookupMode ?? "auto") === "auto" ? (

{rule.targetTable === "cart_items" ? `카드 항목.__cart_id → ${rule.targetTable}.id` : `카드 항목.row_key → ${rule.targetTable}.${columns.find(c => c.isPrimaryKey)?.name ?? "PK(조회중)"}`}

) : (
onUpdate({ manualPkColumn: v })} placeholder="대상 PK 컬럼" />
)}
)} {/* 변경 값 타입 */} {rule.targetColumn && ( <>
{/* 고정값 */} {rule.valueType === "fixed" && (
onUpdate({ fixedValue: e.target.value })} className="h-7 text-xs" placeholder="변경할 값 입력" />
)} {/* 조건부 */} {rule.valueType === "conditional" && (
{conditions.map((cond, cIdx) => (
만약 updateCondition(cIdx, { whenColumn: v })} placeholder="컬럼" /> updateCondition(cIdx, { whenValue: e.target.value })} className="h-7 w-16 text-[10px]" placeholder="값" />
이면 -> updateCondition(cIdx, { thenValue: e.target.value })} className="h-7 text-[10px]" placeholder="변경할 값" />
))}
그 외 -> onUpdate({ conditionalValue: { ...rule.conditionalValue, conditions, defaultValue: e.target.value }, }) } className="h-7 text-[10px]" placeholder="기본값" />
)} )}
); } // 레지스트리 등록 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"], });