/** * executePopAction - POP 액션 실행 순수 함수 * * pop-button, pop-string-list 등 여러 컴포넌트에서 재사용 가능한 * 액션 실행 코어 로직. React 훅에 의존하지 않음. * * 사용처: * - usePopAction 훅 (pop-button용 래퍼) * - pop-string-list 카드 버튼 (직접 호출) * - 향후 pop-table 행 액션 등 */ import type { ButtonMainAction, ButtonTask } from "@/lib/registry/pop-components/pop-button"; import { apiClient } from "@/lib/api/client"; import { dataApi } from "@/lib/api/data"; // ======================================== // 타입 정의 // ======================================== /** 액션 실행 결과 */ export interface ActionResult { success: boolean; data?: unknown; error?: string; } /** 이벤트 발행 함수 시그니처 (usePopEvent의 publish와 동일) */ type PublishFn = (eventName: string, payload?: unknown) => void; /** executePopAction 옵션 */ interface ExecuteOptions { /** 필드 매핑 (소스 컬럼명 → 타겟 컬럼명) */ fieldMapping?: Record; /** 화면 ID (이벤트 발행 시 사용) */ screenId?: string; /** 이벤트 발행 함수 (순수 함수이므로 외부에서 주입) */ publish?: PublishFn; } // ======================================== // 내부 헬퍼 // ======================================== /** * 필드 매핑 적용 * 소스 데이터의 컬럼명을 타겟 테이블 컬럼명으로 변환 */ function applyFieldMapping( rowData: Record, mapping?: Record ): Record { if (!mapping || Object.keys(mapping).length === 0) { return { ...rowData }; } const result: Record = {}; for (const [sourceKey, value] of Object.entries(rowData)) { // 매핑이 있으면 타겟 키로 변환, 없으면 원본 키 유지 const targetKey = mapping[sourceKey] || sourceKey; result[targetKey] = value; } return result; } /** * rowData에서 PK 추출 * id > pk 순서로 시도, 없으면 rowData 전체를 복합키로 사용 */ function extractPrimaryKey( rowData: Record ): string | number | Record { if (rowData.id != null) return rowData.id as string | number; if (rowData.pk != null) return rowData.pk as string | number; // 복합키: rowData 전체를 Record로 전달 (dataApi.deleteRecord가 object 지원) return rowData as Record; } // ======================================== // 메인 함수 // ======================================== /** * POP 액션 실행 (순수 함수) * * @param action - 버튼 메인 액션 설정 * @param rowData - 대상 행 데이터 (리스트 컴포넌트에서 전달) * @param options - 필드 매핑, screenId, publish 함수 * @returns 실행 결과 */ export async function executePopAction( action: ButtonMainAction, rowData?: Record, options?: ExecuteOptions ): Promise { const { fieldMapping, publish } = options || {}; try { switch (action.type) { // ── 저장 ── case "save": { if (!action.targetTable) { return { success: false, error: "저장 대상 테이블이 설정되지 않았습니다." }; } const data = rowData ? applyFieldMapping(rowData, fieldMapping) : {}; const result = await dataApi.createRecord(action.targetTable, data); return { success: !!result?.success, data: result?.data, error: result?.message }; } // ── 삭제 ── case "delete": { if (!action.targetTable) { return { success: false, error: "삭제 대상 테이블이 설정되지 않았습니다." }; } if (!rowData) { return { success: false, error: "삭제할 데이터가 없습니다." }; } const mappedData = applyFieldMapping(rowData, fieldMapping); const pk = extractPrimaryKey(mappedData); const result = await dataApi.deleteRecord(action.targetTable, pk); return { success: !!result?.success, error: result?.message }; } // ── API 호출 ── case "api": { if (!action.apiEndpoint) { return { success: false, error: "API 엔드포인트가 설정되지 않았습니다." }; } const body = rowData ? applyFieldMapping(rowData, fieldMapping) : undefined; const method = (action.apiMethod || "POST").toUpperCase(); let response; switch (method) { case "GET": response = await apiClient.get(action.apiEndpoint, { params: body }); break; case "POST": response = await apiClient.post(action.apiEndpoint, body); break; case "PUT": response = await apiClient.put(action.apiEndpoint, body); break; case "DELETE": response = await apiClient.delete(action.apiEndpoint, { data: body }); break; default: response = await apiClient.post(action.apiEndpoint, body); } const resData = response?.data; return { success: resData?.success !== false, data: resData?.data ?? resData, }; } // ── 모달 열기 ── case "modal": { if (!publish) { return { success: false, error: "이벤트 발행 함수가 제공되지 않았습니다." }; } publish("__pop_modal_open__", { modalId: action.modalScreenId, title: action.modalTitle, mode: action.modalMode, items: action.modalItems, rowData, }); return { success: true }; } // ── 이벤트 발행 ── case "event": { if (!publish) { return { success: false, error: "이벤트 발행 함수가 제공되지 않았습니다." }; } if (!action.eventName) { return { success: false, error: "이벤트 이름이 설정되지 않았습니다." }; } publish(action.eventName, { ...(action.eventPayload || {}), row: rowData, }); return { success: true }; } default: return { success: false, error: `알 수 없는 액션 타입: ${action.type}` }; } } catch (err: unknown) { const message = err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다."; return { success: false, error: message }; } } // ======================================== // v2: 작업 목록 실행 // ======================================== /** 수집된 데이터 구조 */ export interface CollectedPayload { items?: Record[]; fieldValues?: Record; mappings?: { cardList?: Record | null; field?: Record | null; }; cartChanges?: { toCreate?: Record[]; toUpdate?: Record[]; toDelete?: (string | number)[]; }; } /** 작업 목록 실행 옵션 */ interface ExecuteTaskListOptions { publish: PublishFn; componentId: string; collectedData?: CollectedPayload; } /** * 작업 목록을 순차 실행한다. * 데이터 관련 작업(data-save, data-update, data-delete, cart-save)은 * 하나의 API 호출로 묶어 백엔드에서 트랜잭션 처리한다. * 나머지 작업(modal-open, navigate 등)은 프론트엔드에서 직접 처리한다. */ export async function executeTaskList( tasks: ButtonTask[], options: ExecuteTaskListOptions, ): Promise { const { publish, componentId, collectedData } = options; // 데이터 작업과 프론트 전용 작업 분리 const DATA_TASK_TYPES = new Set(["data-save", "data-update", "data-delete", "cart-save"]); const dataTasks = tasks.filter((t) => DATA_TASK_TYPES.has(t.type)); const frontTasks = tasks.filter((t) => !DATA_TASK_TYPES.has(t.type)); let backendData: Record | null = null; try { // 1. 데이터 작업이 있으면 백엔드에 일괄 전송 if (dataTasks.length > 0) { const result = await apiClient.post("/pop/execute-action", { tasks: dataTasks, data: { items: collectedData?.items ?? [], fieldValues: collectedData?.fieldValues ?? {}, }, mappings: collectedData?.mappings ?? {}, cartChanges: collectedData?.cartChanges, }); if (!result.data?.success) { return { success: false, error: result.data?.message || "데이터 작업 실행에 실패했습니다.", data: result.data, }; } backendData = result.data; } const innerData = (backendData as Record)?.data as Record | undefined; const generatedCodes = innerData?.generatedCodes as Array<{ targetColumn: string; code: string; showResultModal?: boolean }> | undefined; const hasResultModal = generatedCodes?.some((g) => g.showResultModal); // 2. 프론트엔드 전용 작업 순차 실행 (채번 모달이 있으면 navigate 보류) const deferredNavigateTasks: ButtonTask[] = []; for (const task of frontTasks) { switch (task.type) { case "modal-open": publish("__pop_modal_open__", { modalId: task.modalScreenId, title: task.modalTitle, mode: task.modalMode, items: task.modalItems, }); break; case "navigate": if (hasResultModal) { deferredNavigateTasks.push(task); } else if (task.targetScreenId) { publish("__pop_navigate__", { screenId: task.targetScreenId, params: task.params }); } break; case "close-modal": publish("__pop_close_modal__"); break; case "refresh": if (!hasResultModal) { publish("__pop_refresh__"); } break; case "api-call": { if (!task.apiEndpoint) break; const method = (task.apiMethod || "POST").toUpperCase(); switch (method) { case "GET": await apiClient.get(task.apiEndpoint); break; case "PUT": await apiClient.put(task.apiEndpoint); break; case "DELETE": await apiClient.delete(task.apiEndpoint); break; default: await apiClient.post(task.apiEndpoint); } break; } case "custom-event": if (task.eventName) { publish(task.eventName, task.eventPayload ?? {}); } break; } } // 3. 완료 이벤트 if (!hasResultModal) { publish(`__comp_output__${componentId}__action_completed`, { action: "task-list", success: true, }); } return { success: true, data: { generatedCodes, deferredTasks: deferredNavigateTasks, ...(backendData ?? {}), }, }; } catch (err: unknown) { const message = err instanceof Error ? err.message : "작업 실행 중 오류가 발생했습니다."; return { success: false, error: message }; } }