fix: update file handling and improve query logging

- Added mes-architecture-guide.md to .gitignore to prevent unnecessary tracking.
- Enhanced NodeFlowExecutionService to merge context data for WHERE clause, improving query accuracy.
- Updated logging to include values in SQL query logs for better debugging.
- Removed redundant event dispatches in V2Repeater to streamline save operations.
- Adjusted DynamicComponentRenderer to conditionally refresh keys based on component type.
- Improved FileUploadComponent to clear localStorage only for modal components, preventing unintended resets in non-modal contexts.

These changes aim to enhance the overall functionality and maintainability of the application, ensuring better data handling and user experience.
This commit is contained in:
kjs 2026-03-18 17:43:03 +09:00
parent 359bf0e614
commit c634e1e054
8 changed files with 95 additions and 87 deletions

1
.gitignore vendored
View File

@ -194,3 +194,4 @@ mcp-task-queue/
# 파이프라인 회고록 (자동 생성)
docs/retrospectives/
mes-architecture-guide.md

View File

@ -952,13 +952,20 @@ export class NodeFlowExecutionService {
}
const schemaPrefix = schema ? `${schema}.` : "";
// WHERE 조건에서 field 값 조회를 위해 컨텍스트 데이터 전달
// sourceData(저장된 폼 데이터) + buttonContext(인증 정보) 병합
const contextForWhere = {
...(context.buttonContext || {}),
...(context.sourceData?.[0] || {}),
};
const whereResult = whereConditions
? this.buildWhereClause(whereConditions)
? this.buildWhereClause(whereConditions, contextForWhere)
: { clause: "", values: [] };
const sql = `SELECT * FROM ${schemaPrefix}${tableName} ${whereResult.clause}`;
logger.info(`📊 테이블 전체 데이터 조회 SQL: ${sql}`);
logger.info(`📊 테이블 전체 데이터 조회 SQL: ${sql}`, { values: whereResult.values });
const result = await query(sql, whereResult.values);

View File

@ -347,7 +347,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
if (!tableName || currentData.length === 0) {
console.warn("🔴 [V2Repeater] 저장 스킵:", { tableName, dataLength: currentData.length });
toast.warning(`[디버그] V2Repeater 저장 스킵: data=${currentData.length}, table=${tableName}`);
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
return;
}
@ -356,7 +355,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
const hasFkSource = sourceCol && mainFormData && mainFormData[sourceCol] !== undefined;
if (!hasFkSource && !masterRecordId) {
console.warn("🔴 [V2Repeater] FK 소스 값/masterRecordId 모두 없어 저장 스킵");
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
return;
}
}

View File

@ -999,7 +999,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const rendererInstance = new RendererClass(rendererProps);
renderedElement = rendererInstance.render();
} else {
renderedElement = <NewComponentRenderer key={refreshKey} {...rendererProps} />;
const needsKeyRefresh =
componentType === "v2-table-list" ||
componentType === "table-list" ||
componentType === "v2-repeater";
renderedElement = <NewComponentRenderer key={needsKeyRefresh ? refreshKey : component.id} {...rendererProps} />;
}
// 수평 라벨 → 라벨을 컴포넌트 영역 바깥에 absolute 배치, 입력은 100% 채움

View File

@ -106,6 +106,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const [forceUpdate, setForceUpdate] = useState(0);
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
@ -217,18 +218,17 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}
}, [component.id, getUniqueKey, recordId, isRecordMode]);
// 🆕 모달 닫힘/저장 성공 시 localStorage 파일 캐시 정리 (등록 후 재등록 시 이전 파일 잔존 방지)
// 모달 닫힘/저장 성공 시 localStorage 파일 캐시 정리 (등록 후 재등록 시 이전 파일 잔존 방지)
// 모달(Dialog) 내부의 컴포넌트만 초기화 대상 - 일반 화면의 파일 업로드는 초기화하지 않음
useEffect(() => {
const handleClearFileCache = (event: Event) => {
// 모달 내부 컴포넌트만 초기화 (일반 화면에서는 스킵)
const isInModal = containerRef.current ? !!containerRef.current.closest('[role="dialog"]') : false;
if (!isInModal) {
return;
}
const backupKey = getUniqueKey();
const eventType = event.type;
console.log("🧹 [DEBUG-CLEAR] 파일 캐시 정리 이벤트 수신:", {
eventType,
backupKey,
componentId: component.id,
currentFiles: uploadedFiles.length,
hasLocalStorage: !!localStorage.getItem(backupKey),
});
try {
localStorage.removeItem(backupKey);
setUploadedFiles([]);
@ -238,22 +238,15 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
delete globalFileState[backupKey];
(window as any).globalFileState = globalFileState;
}
console.log("🧹 [DEBUG-CLEAR] 정리 완료:", backupKey);
} catch (e) {
console.warn("파일 캐시 정리 실패:", e);
}
};
// EditModal 닫힘, ScreenModal 연속 등록 저장 성공, 일반 저장 성공 모두 처리
window.addEventListener("closeEditModal", handleClearFileCache);
window.addEventListener("saveSuccess", handleClearFileCache);
window.addEventListener("saveSuccessInModal", handleClearFileCache);
console.log("🔎 [DEBUG-CLEAR] 이벤트 리스너 등록 완료:", {
componentId: component.id,
backupKey: getUniqueKey(),
});
return () => {
window.removeEventListener("closeEditModal", handleClearFileCache);
window.removeEventListener("saveSuccess", handleClearFileCache);
@ -1190,10 +1183,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
return (
<div
ref={containerRef}
style={{
...componentStyle,
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤
height: "100%", // 🆕 부모 컨테이너 높이에 맞춤
width: "100%",
height: "100%",
border: "none !important",
boxShadow: "none !important",
outline: "none !important",

View File

@ -691,25 +691,27 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
target: "all",
});
// 2. 모달 닫기 (약간의 딜레이)
setTimeout(() => {
// EditModal 내부인지 확인 (isInModal prop 사용)
const isInEditModal = (props as any).isInModal;
// 2. 모달 닫기 (약간의 딜레이, 모달 내부에서만)
const isInEditModal = (props as any).isInModal;
const isInScreenModal = !!(props as any).isScreenModal || !!context.onClose;
if (isInEditModal) {
v2EventBus.emitSync(V2_EVENTS.MODAL_CLOSE, {
modalId: "edit-modal",
reason: "save",
if (isInEditModal || isInScreenModal) {
setTimeout(() => {
if (isInEditModal) {
v2EventBus.emitSync(V2_EVENTS.MODAL_CLOSE, {
modalId: "edit-modal",
reason: "save",
});
}
// ScreenModal 연속 등록 모드 지원
v2EventBus.emitSync(V2_EVENTS.MODAL_SAVE_SUCCESS, {
modalId: "screen-modal",
savedData: context.formData || {},
tableName: context.tableName || "",
});
}
// ScreenModal은 연속 등록 모드를 지원하므로 saveSuccessInModal 이벤트 발생
v2EventBus.emitSync(V2_EVENTS.MODAL_SAVE_SUCCESS, {
modalId: "screen-modal",
savedData: context.formData || {},
tableName: context.tableName || "",
});
}, 100);
}, 100);
}
}
} catch (error) {
// 로딩 토스트 제거

View File

@ -105,6 +105,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const [forceUpdate, setForceUpdate] = useState(0);
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// objid 기반으로 파일이 로드되었는지 추적 (다른 이펙트가 덮어쓰지 않도록 방지)
const filesLoadedFromObjidRef = useRef(false);
@ -197,7 +198,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
useEffect(() => {
if (!imageObjidFromFormData) {
// formData에서 값이 사라지면 파일 목록도 초기화 (새 등록 시)
if (uploadedFiles.length > 0 && !isRecordMode) {
// 단, 모달 내부의 컴포넌트만 초기화 - 일반 화면에서는 저장 후 리셋으로 인한 초기화 방지
const isInModal = containerRef.current ? !!containerRef.current.closest('[role="dialog"]') : false;
if (uploadedFiles.length > 0 && !isRecordMode && isInModal) {
setUploadedFiles([]);
filesLoadedFromObjidRef.current = false;
}
@ -1058,11 +1061,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
return (
<div
ref={containerRef}
style={{
...componentStyle,
width: "100%",
height: "100%",
// 🔧 !important 제거 - 커스텀 스타일이 없을 때만 기본값 적용
border: hasCustomBorder ? undefined : "none",
boxShadow: "none",
outline: "none",

View File

@ -542,15 +542,6 @@ export class ButtonActionExecutor {
this.saveCallCount++;
const callId = this.saveCallCount;
// 🔧 디버그: context.formData 확인 (handleSave 진입 시점)
console.log("🔍 [handleSave] 진입 시 context.formData:", {
keys: Object.keys(context.formData || {}),
hasCompanyImage: "company_image" in (context.formData || {}),
hasCompanyLogo: "company_logo" in (context.formData || {}),
companyImageValue: context.formData?.company_image,
companyLogoValue: context.formData?.company_logo,
});
const { formData, originalData, tableName, screenId, onSave } = context;
// 🆕 중복 호출 방지: 같은 screenId + tableName + formData 조합으로 2초 내 재호출 시 무시
@ -621,6 +612,18 @@ export class ButtonActionExecutor {
if (onSave) {
try {
await onSave();
// 모달 저장 후에도 제어관리 실행 (onSave 경로에서도 dataflow 지원)
if (config.enableDataflowControl && config.dataflowConfig) {
console.log("📦 [handleSave] onSave 콜백 후 제어관리 실행 시작");
const contextWithSavedData = {
...context,
savedData: context.formData,
selectedRowsData: context.selectedRowsData || [],
};
await this.executeAfterSaveControl(config, contextWithSavedData);
}
return true;
} catch (error: any) {
console.error("❌ [handleSave] onSave 콜백 실행 오류:", error);
@ -636,13 +639,6 @@ export class ButtonActionExecutor {
console.log("⚠️ [handleSave] onSave 콜백 없음 - 기본 저장 로직 실행");
// 🔧 디버그: beforeFormSave 이벤트 후 formData 확인
console.log("🔍 [handleSave] beforeFormSave 이벤트 후:", {
keys: Object.keys(context.formData || {}),
hasCompanyImage: "company_image" in (context.formData || {}),
companyImageValue: context.formData?.company_image,
});
// skipDefaultSave 플래그 확인
if (beforeSaveEventDetail.skipDefaultSave) {
return true;
@ -749,11 +745,6 @@ export class ButtonActionExecutor {
return await this.handleBatchSave(config, context, selectedItemsKeys);
} else {
console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행");
// 🔧 디버그: formData 상세 확인
console.log("🔍 [handleSave] formData 키 목록:", Object.keys(context.formData || {}));
console.log("🔍 [handleSave] formData.company_image:", context.formData?.company_image);
console.log("🔍 [handleSave] formData.company_logo:", context.formData?.company_logo);
console.log("⚠️ [handleSave] formData 전체 내용:", context.formData);
}
// 🆕 RepeaterFieldGroup JSON 문자열 파싱 및 저장 처리
@ -1011,6 +1002,9 @@ export class ButtonActionExecutor {
// saveResult를 상위 스코프에서 정의 (repeaterSave 이벤트에서 사용)
let saveResult: { success: boolean; data?: any; message?: string } | undefined;
// 제어 실행 데이터를 상위 스코프에서 정의 (리피터 저장 완료 후 실행 위해)
let pendingDataflowControl: { config: ButtonActionConfig; context: ButtonActionContext } | null = null;
if (tableName && screenId) {
// DB에서 실제 기본키 조회하여 INSERT/UPDATE 자동 판단
const primaryKeyResult = await DynamicFormApi.getTablePrimaryKeys(tableName);
@ -1758,25 +1752,20 @@ export class ButtonActionExecutor {
return false;
}
// 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우)
// 제어 실행 준비 (실제 실행은 리피터 저장 완료 후로 지연)
console.log("🔍 [handleSave] 제어관리 설정 체크:", {
enableDataflowControl: config.enableDataflowControl,
hasDataflowConfig: !!config.dataflowConfig,
flowControls: config.dataflowConfig?.flowControls?.length || 0,
});
if (config.enableDataflowControl && config.dataflowConfig) {
// 테이블 섹션 데이터 파싱 (comp_로 시작하는 필드에 JSON 배열이 있는 경우)
// 입고 화면 등에서 품목 목록이 comp_xxx 필드에 JSON 문자열로 저장됨
// 🔧 수정: saveResult.data가 3단계로 중첩된 경우 실제 폼 데이터 추출
// saveResult.data = API 응답 { success, data, message }
// saveResult.data.data = 저장된 레코드 { id, screenId, tableName, data, createdAt... }
// saveResult.data.data.data = 실제 폼 데이터 { sabun, user_name... }
const savedRecord = saveResult?.data?.data || saveResult?.data || {};
const actualFormData = savedRecord?.data || savedRecord;
const formData: Record<string, any> = (
Object.keys(actualFormData).length > 0 ? actualFormData : context.formData || {}
) as Record<string, any>;
console.log("📦 [executeAfterSaveControl] savedRecord 구조:", Object.keys(savedRecord));
console.log("📦 [executeAfterSaveControl] actualFormData 추출:", Object.keys(formData));
console.log("📦 [executeAfterSaveControl] formData.sabun:", formData.sabun);
let parsedSectionData: any[] = [];
// comp_로 시작하는 필드에서 테이블 섹션 데이터 찾기
const compFieldKey = Object.keys(formData).find(
(key) => key.startsWith("comp_") && typeof formData[key] === "string",
);
@ -1785,11 +1774,8 @@ export class ButtonActionExecutor {
try {
const sectionData = JSON.parse(formData[compFieldKey]);
if (Array.isArray(sectionData) && sectionData.length > 0) {
// 공통 필드와 섹션 데이터 병합
parsedSectionData = sectionData.map((item: any) => {
// 섹션 데이터에서 불필요한 내부 필드 제거
const { _isNewItem, _targetTable, _existingRecord, ...cleanItem } = item;
// 공통 필드(comp_ 필드 제외) + 섹션 아이템 병합
const commonFields: Record<string, any> = {};
Object.keys(formData).forEach((key) => {
if (!key.startsWith("comp_") && !key.endsWith("_numberingRuleId")) {
@ -1804,14 +1790,14 @@ export class ButtonActionExecutor {
}
}
// 저장된 데이터를 context에 추가하여 플로우에 전달
const contextWithSavedData = {
...context,
savedData: formData,
// 파싱된 섹션 데이터가 있으면 selectedRowsData로 전달
selectedRowsData: parsedSectionData.length > 0 ? parsedSectionData : context.selectedRowsData,
pendingDataflowControl = {
config,
context: {
...context,
savedData: formData,
selectedRowsData: parsedSectionData.length > 0 ? parsedSectionData : context.selectedRowsData,
},
};
await this.executeAfterSaveControl(config, contextWithSavedData);
}
} else {
throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
@ -1935,13 +1921,26 @@ export class ButtonActionExecutor {
await repeaterSavePromise;
}
// 리피터 저장 완료 후 제어관리 실행 (디테일 레코드가 DB에 있는 상태에서 실행)
console.log("🔍 [handleSave] 리피터 저장 완료, pendingDataflowControl:", !!pendingDataflowControl);
if (pendingDataflowControl) {
console.log("📦 [handleSave] 리피터 저장 완료 후 제어관리 실행 시작");
await this.executeAfterSaveControl(
pendingDataflowControl.config,
pendingDataflowControl.context,
);
}
// 테이블과 플로우 새로고침 (모달 닫기 전에 실행)
context.onRefresh?.();
context.onFlowRefresh?.();
// 저장 성공 후 모달 닫기 이벤트 발생
window.dispatchEvent(new CustomEvent("closeEditModal"));
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
// 저장 성공 후 모달 닫기 이벤트 발생 (모달 내부에서만)
// 비모달 화면에서 이 이벤트를 발행하면 ScreenModal이 반응하여 컴포넌트 트리 재마운트 발생
if (context.onClose) {
window.dispatchEvent(new CustomEvent("closeEditModal"));
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
}
return true;
} catch (error: any) {