353 lines
11 KiB
TypeScript
353 lines
11 KiB
TypeScript
/**
|
|
* 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<string, string>;
|
|
/** 화면 ID (이벤트 발행 시 사용) */
|
|
screenId?: string;
|
|
/** 이벤트 발행 함수 (순수 함수이므로 외부에서 주입) */
|
|
publish?: PublishFn;
|
|
}
|
|
|
|
// ========================================
|
|
// 내부 헬퍼
|
|
// ========================================
|
|
|
|
/**
|
|
* 필드 매핑 적용
|
|
* 소스 데이터의 컬럼명을 타겟 테이블 컬럼명으로 변환
|
|
*/
|
|
function applyFieldMapping(
|
|
rowData: Record<string, unknown>,
|
|
mapping?: Record<string, string>
|
|
): Record<string, unknown> {
|
|
if (!mapping || Object.keys(mapping).length === 0) {
|
|
return { ...rowData };
|
|
}
|
|
|
|
const result: Record<string, unknown> = {};
|
|
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, unknown>
|
|
): string | number | Record<string, unknown> {
|
|
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<string, unknown>;
|
|
}
|
|
|
|
// ========================================
|
|
// 메인 함수
|
|
// ========================================
|
|
|
|
/**
|
|
* POP 액션 실행 (순수 함수)
|
|
*
|
|
* @param action - 버튼 메인 액션 설정
|
|
* @param rowData - 대상 행 데이터 (리스트 컴포넌트에서 전달)
|
|
* @param options - 필드 매핑, screenId, publish 함수
|
|
* @returns 실행 결과
|
|
*/
|
|
export async function executePopAction(
|
|
action: ButtonMainAction,
|
|
rowData?: Record<string, unknown>,
|
|
options?: ExecuteOptions
|
|
): Promise<ActionResult> {
|
|
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<string, unknown>[];
|
|
fieldValues?: Record<string, unknown>;
|
|
mappings?: {
|
|
cardList?: Record<string, unknown> | null;
|
|
field?: Record<string, unknown> | null;
|
|
};
|
|
cartChanges?: {
|
|
toCreate?: Record<string, unknown>[];
|
|
toUpdate?: Record<string, unknown>[];
|
|
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<ActionResult> {
|
|
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<string, unknown> | 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<string, unknown>)?.data as Record<string, unknown> | 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 };
|
|
}
|
|
}
|