Merge origin/main and resolve conflicts - add geolocation/update_field actions
This commit is contained in:
@@ -26,7 +26,8 @@ export type ButtonActionType =
|
||||
| "code_merge" // 코드 병합
|
||||
| "geolocation" // 위치정보 가져오기
|
||||
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
|
||||
| "update_field"; // 특정 필드 값 변경 (예: status를 active로)
|
||||
| "update_field" // 특정 필드 값 변경 (예: status를 active로)
|
||||
| "transferData"; // 🆕 데이터 전달 (컴포넌트 간 or 화면 간)
|
||||
|
||||
/**
|
||||
* 버튼 액션 설정
|
||||
@@ -126,6 +127,43 @@ export interface ButtonActionConfig {
|
||||
editModalTitle?: string; // 편집 모달 제목
|
||||
editModalDescription?: string; // 편집 모달 설명
|
||||
groupByColumns?: string[]; // 같은 그룹의 여러 행을 함께 편집 (예: ["order_no"])
|
||||
|
||||
// 데이터 전달 관련 (transferData 액션용)
|
||||
dataTransfer?: {
|
||||
// 소스 설정
|
||||
sourceComponentId: string; // 데이터를 가져올 컴포넌트 ID (테이블 등)
|
||||
sourceComponentType?: string; // 소스 컴포넌트 타입
|
||||
|
||||
// 타겟 설정
|
||||
targetType: "component" | "screen"; // 타겟 타입 (같은 화면의 컴포넌트 or 다른 화면)
|
||||
|
||||
// 타겟이 컴포넌트인 경우
|
||||
targetComponentId?: string; // 타겟 컴포넌트 ID
|
||||
|
||||
// 타겟이 화면인 경우
|
||||
targetScreenId?: number; // 타겟 화면 ID
|
||||
|
||||
// 데이터 매핑 규칙
|
||||
mappingRules: Array<{
|
||||
sourceField: string; // 소스 필드명
|
||||
targetField: string; // 타겟 필드명
|
||||
transform?: "sum" | "average" | "concat" | "first" | "last" | "count"; // 변환 함수
|
||||
defaultValue?: any; // 기본값
|
||||
}>;
|
||||
|
||||
// 전달 옵션
|
||||
mode?: "append" | "replace" | "merge"; // 수신 모드 (기본: append)
|
||||
clearAfterTransfer?: boolean; // 전달 후 소스 데이터 초기화
|
||||
confirmBeforeTransfer?: boolean; // 전달 전 확인 메시지
|
||||
confirmMessage?: string; // 확인 메시지 내용
|
||||
|
||||
// 검증
|
||||
validation?: {
|
||||
requireSelection?: boolean; // 선택 필수 (기본: true)
|
||||
minSelection?: number; // 최소 선택 개수
|
||||
maxSelection?: number; // 최대 선택 개수
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -455,6 +493,66 @@ export class ButtonActionExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 반복 필드 그룹에서 삭제된 항목 처리
|
||||
// formData의 각 필드에서 _deletedItemIds가 있는지 확인
|
||||
console.log("🔍 [handleSave] 삭제 항목 검색 시작 - dataWithUserInfo 키:", Object.keys(dataWithUserInfo));
|
||||
|
||||
for (const [key, value] of Object.entries(dataWithUserInfo)) {
|
||||
console.log(`🔍 [handleSave] 필드 검사: ${key}`, {
|
||||
type: typeof value,
|
||||
isArray: Array.isArray(value),
|
||||
isString: typeof value === "string",
|
||||
valuePreview: typeof value === "string" ? value.substring(0, 100) : value,
|
||||
});
|
||||
|
||||
let parsedValue = value;
|
||||
|
||||
// JSON 문자열인 경우 파싱 시도
|
||||
if (typeof value === "string" && value.startsWith("[")) {
|
||||
try {
|
||||
parsedValue = JSON.parse(value);
|
||||
console.log(`🔍 [handleSave] JSON 파싱 성공: ${key}`, parsedValue);
|
||||
} catch (e) {
|
||||
// 파싱 실패하면 원본 값 유지
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(parsedValue) && parsedValue.length > 0) {
|
||||
const firstItem = parsedValue[0];
|
||||
const deletedItemIds = firstItem?._deletedItemIds;
|
||||
const targetTable = firstItem?._targetTable;
|
||||
|
||||
console.log(`🔍 [handleSave] 배열 필드 분석: ${key}`, {
|
||||
firstItemKeys: firstItem ? Object.keys(firstItem) : [],
|
||||
deletedItemIds,
|
||||
targetTable,
|
||||
});
|
||||
|
||||
if (deletedItemIds && deletedItemIds.length > 0 && targetTable) {
|
||||
console.log("🗑️ [handleSave] 삭제할 항목 발견:", {
|
||||
fieldKey: key,
|
||||
targetTable,
|
||||
deletedItemIds,
|
||||
});
|
||||
|
||||
// 삭제 API 호출
|
||||
for (const itemId of deletedItemIds) {
|
||||
try {
|
||||
console.log(`🗑️ [handleSave] 항목 삭제 중: ${itemId} from ${targetTable}`);
|
||||
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(itemId, targetTable);
|
||||
if (deleteResult.success) {
|
||||
console.log(`✅ [handleSave] 항목 삭제 완료: ${itemId}`);
|
||||
} else {
|
||||
console.warn(`⚠️ [handleSave] 항목 삭제 실패: ${itemId}`, deleteResult.message);
|
||||
}
|
||||
} catch (deleteError) {
|
||||
console.error(`❌ [handleSave] 항목 삭제 오류: ${itemId}`, deleteError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveResult = await DynamicFormApi.saveFormData({
|
||||
screenId,
|
||||
tableName,
|
||||
@@ -979,6 +1077,7 @@ export class ButtonActionExecutor {
|
||||
title: config.modalTitle,
|
||||
size: config.modalSize,
|
||||
targetScreenId: config.targetScreenId,
|
||||
selectedRowsData: context.selectedRowsData,
|
||||
});
|
||||
|
||||
if (config.targetScreenId) {
|
||||
@@ -995,6 +1094,10 @@ export class ButtonActionExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 선택된 행 데이터 수집
|
||||
const selectedData = context.selectedRowsData || [];
|
||||
console.log("📦 [handleModal] 선택된 데이터:", selectedData);
|
||||
|
||||
// 전역 모달 상태 업데이트를 위한 이벤트 발생
|
||||
const modalEvent = new CustomEvent("openScreenModal", {
|
||||
detail: {
|
||||
@@ -1002,6 +1105,9 @@ export class ButtonActionExecutor {
|
||||
title: config.modalTitle || "화면",
|
||||
description: description,
|
||||
size: config.modalSize || "md",
|
||||
// 🆕 선택된 행 데이터 전달
|
||||
selectedData: selectedData,
|
||||
selectedIds: selectedData.map((row: any) => row.id).filter(Boolean),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1382,16 +1488,59 @@ export class ButtonActionExecutor {
|
||||
let description = config.editModalDescription || "";
|
||||
|
||||
// 2. config에 없으면 화면 정보에서 가져오기
|
||||
if (!description && config.targetScreenId) {
|
||||
let screenInfo: any = null;
|
||||
if (config.targetScreenId) {
|
||||
try {
|
||||
const screenInfo = await screenApi.getScreen(config.targetScreenId);
|
||||
description = screenInfo?.description || "";
|
||||
screenInfo = await screenApi.getScreen(config.targetScreenId);
|
||||
if (!description) {
|
||||
description = screenInfo?.description || "";
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("화면 설명을 가져오지 못했습니다:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 항상 EditModal 사용 (groupByColumns는 EditModal에서 처리)
|
||||
// 🆕 화면이 분할 패널을 포함하는지 확인 (레이아웃에 screen-split-panel 컴포넌트가 있는지)
|
||||
let hasSplitPanel = false;
|
||||
if (config.targetScreenId) {
|
||||
try {
|
||||
const layoutData = await screenApi.getLayout(config.targetScreenId);
|
||||
if (layoutData?.components) {
|
||||
hasSplitPanel = layoutData.components.some(
|
||||
(comp: any) =>
|
||||
comp.type === "screen-split-panel" ||
|
||||
comp.componentType === "screen-split-panel" ||
|
||||
comp.type === "split-panel-layout" ||
|
||||
comp.componentType === "split-panel-layout"
|
||||
);
|
||||
}
|
||||
console.log("🔍 [openEditModal] 분할 패널 확인:", {
|
||||
targetScreenId: config.targetScreenId,
|
||||
hasSplitPanel,
|
||||
componentTypes: layoutData?.components?.map((c: any) => c.type || c.componentType) || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("레이아웃 정보를 가져오지 못했습니다:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 분할 패널 화면인 경우 ScreenModal 사용 (editData 전달)
|
||||
if (hasSplitPanel) {
|
||||
console.log("📋 [openEditModal] 분할 패널 화면 - ScreenModal 사용");
|
||||
const screenModalEvent = new CustomEvent("openScreenModal", {
|
||||
detail: {
|
||||
screenId: config.targetScreenId,
|
||||
title: config.editModalTitle || "데이터 수정",
|
||||
description: description,
|
||||
size: config.modalSize || "lg",
|
||||
editData: rowData, // 🆕 수정 데이터 전달
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(screenModalEvent);
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔧 일반 화면은 EditModal 사용 (groupByColumns는 EditModal에서 처리)
|
||||
const modalEvent = new CustomEvent("openEditModal", {
|
||||
detail: {
|
||||
screenId: config.targetScreenId,
|
||||
|
||||
284
frontend/lib/utils/dataMapping.ts
Normal file
284
frontend/lib/utils/dataMapping.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 데이터 매핑 유틸리티
|
||||
* 화면 간 데이터 전달 시 매핑 규칙 적용
|
||||
*/
|
||||
|
||||
import type {
|
||||
MappingRule,
|
||||
Condition,
|
||||
TransformFunction,
|
||||
} from "@/types/screen-embedding";
|
||||
import { logger } from "./logger";
|
||||
|
||||
/**
|
||||
* 매핑 규칙 적용
|
||||
* @param data 배열 또는 단일 객체
|
||||
* @param rules 매핑 규칙 배열
|
||||
* @returns 매핑된 배열
|
||||
*/
|
||||
export function applyMappingRules(data: any[] | any, rules: MappingRule[]): any[] {
|
||||
// 빈 데이터 처리
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 🆕 배열이 아닌 경우 배열로 변환
|
||||
const dataArray = Array.isArray(data) ? data : [data];
|
||||
|
||||
if (dataArray.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 규칙이 없으면 원본 데이터 반환
|
||||
if (!rules || rules.length === 0) {
|
||||
return dataArray;
|
||||
}
|
||||
|
||||
// 변환 함수가 있는 규칙 확인
|
||||
const hasTransform = rules.some((rule) => rule.transform && rule.transform !== "none");
|
||||
|
||||
if (hasTransform) {
|
||||
// 변환 함수가 있으면 단일 값 또는 집계 결과 반환
|
||||
return [applyTransformRules(dataArray, rules)];
|
||||
}
|
||||
|
||||
// 일반 매핑 (각 행에 대해 매핑)
|
||||
// 🆕 원본 데이터를 복사한 후 매핑 규칙 적용 (매핑되지 않은 필드도 유지)
|
||||
return dataArray.map((row) => {
|
||||
// 원본 데이터 복사
|
||||
const mappedRow: any = { ...row };
|
||||
|
||||
for (const rule of rules) {
|
||||
// sourceField와 targetField가 모두 있어야 매핑 적용
|
||||
if (!rule.sourceField || !rule.targetField) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceValue = getNestedValue(row, rule.sourceField);
|
||||
const targetValue = sourceValue ?? rule.defaultValue;
|
||||
|
||||
// 소스 필드와 타겟 필드가 다르면 소스 필드 제거 후 타겟 필드에 설정
|
||||
if (rule.sourceField !== rule.targetField) {
|
||||
delete mappedRow[rule.sourceField];
|
||||
}
|
||||
|
||||
setNestedValue(mappedRow, rule.targetField, targetValue);
|
||||
}
|
||||
|
||||
return mappedRow;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 변환 함수 적용
|
||||
*/
|
||||
function applyTransformRules(data: any[], rules: MappingRule[]): any {
|
||||
const result: any = {};
|
||||
|
||||
for (const rule of rules) {
|
||||
const values = data.map((row) => getNestedValue(row, rule.sourceField));
|
||||
const transformedValue = applyTransform(values, rule.transform || "none");
|
||||
|
||||
setNestedValue(result, rule.targetField, transformedValue);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 변환 함수 실행
|
||||
*/
|
||||
function applyTransform(values: any[], transform: TransformFunction): any {
|
||||
switch (transform) {
|
||||
case "none":
|
||||
return values;
|
||||
|
||||
case "sum":
|
||||
return values.reduce((sum, val) => sum + (Number(val) || 0), 0);
|
||||
|
||||
case "average":
|
||||
const sum = values.reduce((s, val) => s + (Number(val) || 0), 0);
|
||||
return values.length > 0 ? sum / values.length : 0;
|
||||
|
||||
case "count":
|
||||
return values.length;
|
||||
|
||||
case "min":
|
||||
return Math.min(...values.map((v) => Number(v) || 0));
|
||||
|
||||
case "max":
|
||||
return Math.max(...values.map((v) => Number(v) || 0));
|
||||
|
||||
case "first":
|
||||
return values[0];
|
||||
|
||||
case "last":
|
||||
return values[values.length - 1];
|
||||
|
||||
case "concat":
|
||||
return values.filter((v) => v != null).join("");
|
||||
|
||||
case "join":
|
||||
return values.filter((v) => v != null).join(", ");
|
||||
|
||||
case "custom":
|
||||
// TODO: 커스텀 함수 실행
|
||||
logger.warn("커스텀 변환 함수는 아직 구현되지 않았습니다.");
|
||||
return values;
|
||||
|
||||
default:
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건에 따른 데이터 필터링
|
||||
*/
|
||||
export function filterDataByCondition(data: any[], condition: Condition): any[] {
|
||||
return data.filter((row) => {
|
||||
const value = getNestedValue(row, condition.field);
|
||||
return evaluateCondition(value, condition.operator, condition.value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건 평가
|
||||
*/
|
||||
function evaluateCondition(value: any, operator: string, targetValue: any): boolean {
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
return value === targetValue;
|
||||
|
||||
case "notEquals":
|
||||
return value !== targetValue;
|
||||
|
||||
case "contains":
|
||||
return String(value).includes(String(targetValue));
|
||||
|
||||
case "notContains":
|
||||
return !String(value).includes(String(targetValue));
|
||||
|
||||
case "greaterThan":
|
||||
return Number(value) > Number(targetValue);
|
||||
|
||||
case "lessThan":
|
||||
return Number(value) < Number(targetValue);
|
||||
|
||||
case "greaterThanOrEqual":
|
||||
return Number(value) >= Number(targetValue);
|
||||
|
||||
case "lessThanOrEqual":
|
||||
return Number(value) <= Number(targetValue);
|
||||
|
||||
case "in":
|
||||
return Array.isArray(targetValue) && targetValue.includes(value);
|
||||
|
||||
case "notIn":
|
||||
return Array.isArray(targetValue) && !targetValue.includes(value);
|
||||
|
||||
default:
|
||||
logger.warn(`알 수 없는 조건 연산자: ${operator}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 중첩된 객체에서 값 가져오기
|
||||
* 예: "user.address.city" -> obj.user.address.city
|
||||
*/
|
||||
function getNestedValue(obj: any, path: string): any {
|
||||
if (!obj || !path) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keys = path.split(".");
|
||||
let value = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
value = value[key];
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 중첩된 객체에 값 설정
|
||||
* 예: "user.address.city", "Seoul" -> obj.user.address.city = "Seoul"
|
||||
*/
|
||||
function setNestedValue(obj: any, path: string, value: any): void {
|
||||
if (!obj || !path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = path.split(".");
|
||||
const lastKey = keys.pop()!;
|
||||
let current = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (!(key in current)) {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
|
||||
current[lastKey] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 결과 검증
|
||||
*/
|
||||
export function validateMappingResult(
|
||||
data: any[],
|
||||
rules: MappingRule[]
|
||||
): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 필수 필드 검증
|
||||
const requiredRules = rules.filter((rule) => rule.required);
|
||||
|
||||
for (const rule of requiredRules) {
|
||||
const hasValue = data.some((row) => {
|
||||
const value = getNestedValue(row, rule.targetField);
|
||||
return value != null && value !== "";
|
||||
});
|
||||
|
||||
if (!hasValue) {
|
||||
errors.push(`필수 필드 누락: ${rule.targetField}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 규칙 미리보기
|
||||
* 실제 데이터 전달 전에 결과를 미리 확인
|
||||
*/
|
||||
export function previewMapping(
|
||||
sampleData: any[],
|
||||
rules: MappingRule[]
|
||||
): { success: boolean; preview: any[]; errors?: string[] } {
|
||||
try {
|
||||
const preview = applyMappingRules(sampleData.slice(0, 5), rules);
|
||||
const validation = validateMappingResult(preview, rules);
|
||||
|
||||
return {
|
||||
success: validation.valid,
|
||||
preview,
|
||||
errors: validation.errors,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
preview: [],
|
||||
errors: [error.message],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -864,11 +864,14 @@ export class ImprovedButtonActionExecutor {
|
||||
context: ButtonExecutionContext,
|
||||
): Promise<ExecutionResult> {
|
||||
try {
|
||||
// 기존 ButtonActionExecutor 로직을 여기서 호출하거나
|
||||
// 간단한 액션들을 직접 구현
|
||||
const startTime = performance.now();
|
||||
|
||||
// 임시 구현 - 실제로는 기존 ButtonActionExecutor를 호출해야 함
|
||||
// transferData 액션 처리
|
||||
if (buttonConfig.actionType === "transferData") {
|
||||
return await this.executeTransferDataAction(buttonConfig, formData, context);
|
||||
}
|
||||
|
||||
// 기존 액션들 (임시 구현)
|
||||
const result = {
|
||||
success: true,
|
||||
message: `${buttonConfig.actionType} 액션 실행 완료`,
|
||||
@@ -889,6 +892,43 @@ export class ImprovedButtonActionExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 전달 액션 실행
|
||||
*/
|
||||
private static async executeTransferDataAction(
|
||||
buttonConfig: ExtendedButtonTypeConfig,
|
||||
formData: Record<string, any>,
|
||||
context: ButtonExecutionContext,
|
||||
): Promise<ExecutionResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const dataTransferConfig = buttonConfig.dataTransfer;
|
||||
|
||||
if (!dataTransferConfig) {
|
||||
throw new Error("데이터 전달 설정이 없습니다.");
|
||||
}
|
||||
|
||||
console.log("📦 데이터 전달 시작:", dataTransferConfig);
|
||||
|
||||
// 1. 화면 컨텍스트에서 소스 컴포넌트 찾기
|
||||
const { ScreenContextProvider } = await import("@/contexts/ScreenContext");
|
||||
// 실제로는 현재 화면의 컨텍스트를 사용해야 하지만, 여기서는 전역적으로 접근할 수 없음
|
||||
// 대신 context에 screenContext를 전달하도록 수정 필요
|
||||
|
||||
throw new Error("데이터 전달 기능은 버튼 컴포넌트에서 직접 구현되어야 합니다.");
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ 데이터 전달 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: `데이터 전달 실패: ${error.message}`,
|
||||
executionTime: performance.now() - startTime,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 실행 오류 처리 및 롤백
|
||||
*/
|
||||
|
||||
52
frontend/lib/utils/logger.ts
Normal file
52
frontend/lib/utils/logger.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 프론트엔드 로거 유틸리티
|
||||
*/
|
||||
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
class Logger {
|
||||
private isDevelopment = process.env.NODE_ENV === "development";
|
||||
|
||||
private log(level: LogLevel, message: string, data?: any) {
|
||||
if (!this.isDevelopment && level === "debug") {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
|
||||
|
||||
switch (level) {
|
||||
case "debug":
|
||||
console.debug(prefix, message, data || "");
|
||||
break;
|
||||
case "info":
|
||||
console.info(prefix, message, data || "");
|
||||
break;
|
||||
case "warn":
|
||||
console.warn(prefix, message, data || "");
|
||||
break;
|
||||
case "error":
|
||||
console.error(prefix, message, data || "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
debug(message: string, data?: any) {
|
||||
this.log("debug", message, data);
|
||||
}
|
||||
|
||||
info(message: string, data?: any) {
|
||||
this.log("info", message, data);
|
||||
}
|
||||
|
||||
warn(message: string, data?: any) {
|
||||
this.log("warn", message, data);
|
||||
}
|
||||
|
||||
error(message: string, data?: any) {
|
||||
this.log("error", message, data);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
|
||||
Reference in New Issue
Block a user