From 2e500f066f25d7ece2d8791cae3d695e1a0fb3cd Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 9 Feb 2026 13:22:48 +0900 Subject: [PATCH] feat: Add close confirmation dialog to ScreenModal and enhance SelectedItemsDetailInputComponent - Implemented a confirmation dialog in ScreenModal to prevent accidental closure, allowing users to confirm before exiting and potentially losing unsaved data. - Enhanced SelectedItemsDetailInputComponent by ensuring that base records are created even when detail data is absent, maintaining item-client mapping. - Improved logging for better traceability during the UPSERT process and refined the handling of parent data mappings for more robust data management. --- frontend/components/common/ScreenModal.tsx | 124 ++++++- .../SelectedItemsDetailInputComponent.tsx | 329 ++++++++++++++---- mcp-agent-orchestrator/src/index.ts | 117 ++++--- 3 files changed, 454 insertions(+), 116 deletions(-) diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 49fb3355..0add43d6 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -1,7 +1,17 @@ "use client"; -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useRef, useCallback } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, + AlertDialogAction, +} from "@/components/ui/alert-dialog"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; @@ -67,6 +77,9 @@ export const ScreenModal: React.FC = ({ className }) => { // 화면 리셋 키 (컴포넌트 강제 리마운트용) const [resetKey, setResetKey] = useState(0); + // 모달 닫기 확인 다이얼로그 표시 상태 + const [showCloseConfirm, setShowCloseConfirm] = useState(false); + // localStorage에서 연속 모드 상태 복원 useEffect(() => { const savedMode = localStorage.getItem("screenModal_continuousMode"); @@ -218,10 +231,33 @@ export const ScreenModal: React.FC = ({ className }) => { const parentDataMapping = splitPanelContext?.parentDataMapping || []; // 부모 데이터 소스 - const rawParentData = - splitPanelParentData && Object.keys(splitPanelParentData).length > 0 - ? splitPanelParentData - : splitPanelContext?.selectedLeftData || {}; + // 🔧 수정: 여러 소스를 병합 (우선순위: splitPanelParentData > selectedLeftData > 기존 formData의 링크 필드) + // 예: screen 150→226→227 전환 시: + // - splitPanelParentData: item_info 데이터 (screen 226에서 전달) + // - selectedLeftData: customer_mng 데이터 (SplitPanel 좌측 선택) + // - 기존 formData: 이전 모달에서 설정된 link 필드 (customer_code 등) + const contextData = splitPanelContext?.selectedLeftData || {}; + const eventData = splitPanelParentData && Object.keys(splitPanelParentData).length > 0 + ? splitPanelParentData + : {}; + + // 🆕 기존 formData에서 link 필드(_code, _id)를 가져와 base로 사용 + // 모달 체인(226→227)에서 이전 모달의 연결 필드가 유지됨 + const previousLinkFields: Record = {}; + if (formData && typeof formData === "object" && !Array.isArray(formData)) { + const linkFieldPatterns = ["_code", "_id"]; + const excludeFields = ["id", "created_date", "updated_date", "created_at", "updated_at", "writer"]; + for (const [key, value] of Object.entries(formData)) { + if (excludeFields.includes(key)) continue; + if (value === undefined || value === null) continue; + const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern)); + if (isLinkField) { + previousLinkFields[key] = value; + } + } + } + + const rawParentData = { ...previousLinkFields, ...contextData, ...eventData }; // 🔧 신규 등록 모드에서는 연결에 필요한 필드만 전달 const parentData: Record = {}; @@ -495,14 +531,31 @@ export const ScreenModal: React.FC = ({ className }) => { } }; - const handleClose = () => { - // 🔧 URL 파라미터 제거 (mode, editId, tableName 등) + // 사용자가 바깥 클릭/ESC/X 버튼으로 닫으려 할 때 확인 다이얼로그 표시 + const handleCloseAttempt = useCallback(() => { + setShowCloseConfirm(true); + }, []); + + // 확인 후 실제로 모달을 닫는 함수 + const handleConfirmClose = useCallback(() => { + setShowCloseConfirm(false); + handleCloseInternal(); + }, []); + + // 닫기 취소 (계속 작업) + const handleCancelClose = useCallback(() => { + setShowCloseConfirm(false); + }, []); + + const handleCloseInternal = () => { + // 🔧 URL 파라미터 제거 (mode, editId, tableName, groupByColumns, dataSourceId 등) if (typeof window !== "undefined") { const currentUrl = new URL(window.location.href); currentUrl.searchParams.delete("mode"); currentUrl.searchParams.delete("editId"); currentUrl.searchParams.delete("tableName"); currentUrl.searchParams.delete("groupByColumns"); + currentUrl.searchParams.delete("dataSourceId"); window.history.pushState({}, "", currentUrl.toString()); } @@ -514,8 +567,15 @@ export const ScreenModal: React.FC = ({ className }) => { }); setScreenData(null); setFormData({}); // 폼 데이터 초기화 + setOriginalData(null); // 원본 데이터 초기화 + setSelectedData([]); // 선택된 데이터 초기화 + setContinuousMode(false); + localStorage.setItem("screenModal_continuousMode", "false"); }; + // 기존 handleClose를 유지 (이벤트 핸들러 등에서 사용) + const handleClose = handleCloseInternal; + // 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터 const getModalStyle = () => { if (!screenDimensions) { @@ -615,10 +675,28 @@ export const ScreenModal: React.FC = ({ className }) => { ]); return ( - + { + // X 버튼 클릭 시에도 확인 다이얼로그 표시 + if (!open) { + handleCloseAttempt(); + } + }} + > { + e.preventDefault(); + handleCloseAttempt(); + }} + // ESC 키 누를 때도 바로 닫히지 않도록 방지 + onEscapeKeyDown={(e) => { + e.preventDefault(); + handleCloseAttempt(); + }} >
@@ -838,6 +916,36 @@ export const ScreenModal: React.FC = ({ className }) => {
+ + {/* 모달 닫기 확인 다이얼로그 */} + + + + + 화면을 닫으시겠습니까? + + + 지금 나가시면 진행 중인 데이터가 저장되지 않습니다. +
+ 계속 작업하시려면 '계속 작업' 버튼을 눌러주세요. +
+
+ + + 계속 작업 + + + 나가기 + + +
+
); }; diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index 27feafe2..e99fd0e5 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -490,22 +490,25 @@ export const SelectedItemsDetailInputComponent: React.FC arr.length === 0); if (allGroupsEmpty) { - // 🔧 아이템이 1개뿐이면 기본 레코드 생성 (첫 저장 시) - // 아이템이 여러 개면 빈 아이템은 건너뛰기 (불필요한 NULL 레코드 방지) - if (itemsList.length === 1) { - console.log("📝 [generateCartesianProduct] 단일 아이템, 모든 그룹 비어있음 - 기본 레코드 생성", { - itemIndex, - itemId: item.id, - }); - // 빈 객체를 추가하면 parentKeys와 합쳐져서 기본 레코드가 됨 - allRecords.push({}); - } else { - console.log("⏭️ [generateCartesianProduct] 다중 아이템 중 빈 아이템 - 건너뜀", { - itemIndex, - itemId: item.id, - totalItems: itemsList.length, - }); - } + // 디테일 데이터가 없어도 기본 레코드 생성 (품목-거래처 매핑 유지) + // autoFillFrom 필드 (item_id 등)는 반드시 포함시켜야 나중에 식별 가능 + const baseRecord: Record = {}; + additionalFields.forEach((f) => { + if (f.autoFillFrom && item.originalData) { + const value = item.originalData[f.autoFillFrom]; + if (value !== undefined && value !== null) { + baseRecord[f.name] = value; + } + } + }); + + console.log("📝 [generateCartesianProduct] 모든 그룹 비어있음 - 기본 레코드 생성 (매핑 유지)", { + itemIndex, + itemId: item.id, + baseRecord, + totalItems: itemsList.length, + }); + allRecords.push(baseRecord); return; } @@ -579,17 +582,15 @@ export const SelectedItemsDetailInputComponent: React.FC 0; - console.log("📝 [SelectedItemsDetailInput] 저장 모드:", { mode, isEditMode }); + console.log("📝 [SelectedItemsDetailInput] 저장 모드:", { hasParentMapping }); - if (isEditMode && componentConfig.parentDataMapping && componentConfig.parentDataMapping.length > 0) { - // 🔄 수정 모드: UPSERT API 사용 + if (hasParentMapping) { + // UPSERT API로 직접 DB 저장 try { - console.log("🔄 [SelectedItemsDetailInput] UPSERT 모드로 저장 시작"); + console.log("🔄 [SelectedItemsDetailInput] UPSERT 저장 시작"); console.log("📋 [SelectedItemsDetailInput] componentConfig:", { targetTable: componentConfig.targetTable, parentDataMapping: componentConfig.parentDataMapping, @@ -622,14 +623,30 @@ export const SelectedItemsDetailInputComponent: React.FC { - // 🆕 Entity Join 필드도 처리 (예: customer_code -> customer_id_customer_code) - const value = getFieldValue(sourceData, mapping.sourceField); + // 1차: formData(sourceData)에서 찾기 + let value = getFieldValue(sourceData, mapping.sourceField); + + // 2차: formData에 없으면 dataRegistry[sourceTable]에서 찾기 + // v2-split-panel-layout에서 좌측 항목 선택 시 dataRegistry에 저장한 데이터 활용 + if ((value === undefined || value === null) && mapping.sourceTable) { + const registryData = dataRegistry[mapping.sourceTable]; + if (registryData && registryData.length > 0) { + const registryItem = registryData[0].originalData || registryData[0]; + value = registryItem[mapping.sourceField]; + console.log( + `🔄 [parentKeys] dataRegistry["${mapping.sourceTable}"]에서 찾음: ${mapping.sourceField} =`, + value, + ); + } + } + if (value !== undefined && value !== null) { parentKeys[mapping.targetField] = value; console.log(`✅ [parentKeys] ${mapping.sourceField} → ${mapping.targetField}:`, value); } else { console.warn( `⚠️ [SelectedItemsDetailInput] 부모 키 누락: ${mapping.sourceField} → ${mapping.targetField}`, + `(sourceData, dataRegistry["${mapping.sourceTable}"] 모두 확인)`, ); } }); @@ -657,15 +674,6 @@ export const SelectedItemsDetailInputComponent: React.FC(); + groups.forEach((group) => { + const table = group.sourceTable || mainTable; + if (!groupsByTable.has(table)) { + groupsByTable.set(table, []); + } + groupsByTable.get(table)!.push(group); }); - // UPSERT API 호출 - const { dataApi } = await import("@/lib/api/data"); - const result = await dataApi.upsertGroupedRecords(componentConfig.targetTable, parentKeys, records); + // 디테일 테이블이 있는지 확인 (mainTable과 다른 sourceTable) + const detailTables = [...groupsByTable.keys()].filter((t) => t !== mainTable); + const hasDetailTable = detailTables.length > 0; - if (result.success) { - console.log("✅ [SelectedItemsDetailInput] UPSERT 성공:", { - inserted: result.inserted, - updated: result.updated, - deleted: result.deleted, + console.log("🏗️ [SelectedItemsDetailInput] 저장 구조:", { + mainTable, + detailTables, + hasDetailTable, + groupsByTable: Object.fromEntries(groupsByTable), + }); + + if (hasDetailTable) { + // ============================================================ + // 🆕 2단계 저장: 메인 테이블 + 디테일 테이블 분리 저장 + // 예: customer_item_mapping (매핑) + customer_item_prices (가격) + // ============================================================ + const mainGroups = groupsByTable.get(mainTable) || []; + let totalInserted = 0; + let totalUpdated = 0; + + for (const item of items) { + // Step 1: 메인 테이블 매핑 레코드 생성/갱신 + const mappingData: Record = { ...parentKeys }; + + // 메인 그룹 필드 추출 (customer_item_code, customer_item_name 등) + mainGroups.forEach((group) => { + const entries = item.fieldGroups[group.id] || []; + const groupFields = additionalFields.filter((f) => f.groupId === group.id); + + if (entries.length > 0) { + groupFields.forEach((field) => { + if (entries[0][field.name] !== undefined) { + mappingData[field.name] = entries[0][field.name]; + } + }); + } + + // autoFillFrom 필드 처리 (item_id 등) + groupFields.forEach((field) => { + if (field.autoFillFrom && item.originalData) { + const value = item.originalData[field.autoFillFrom]; + if (value !== undefined && value !== null) { + mappingData[field.name] = value; + } + } + }); + }); + + console.log("📋 [2단계 저장] Step 1 - 매핑 데이터:", mappingData); + + // 기존 매핑 레코드 찾기 + let mappingId: string | null = null; + const searchFilters: Record = {}; + + // parentKeys + item_id로 검색 + Object.entries(parentKeys).forEach(([key, value]) => { + searchFilters[key] = value; + }); + if (mappingData.item_id) { + searchFilters.item_id = mappingData.item_id; + } + + try { + const searchResult = await dataApi.getTableData(mainTable, { + filters: searchFilters, + size: 1, + }); + + if (searchResult.data && searchResult.data.length > 0) { + // 기존 매핑 업데이트 + mappingId = searchResult.data[0].id; + console.log("📌 [2단계 저장] 기존 매핑 발견:", mappingId); + await dataApi.updateRecord(mainTable, mappingId, mappingData); + totalUpdated++; + } else { + // 새 매핑 생성 + const createResult = await dataApi.createRecord(mainTable, mappingData); + if (createResult.success && createResult.data) { + mappingId = createResult.data.id; + console.log("✨ [2단계 저장] 새 매핑 생성:", mappingId); + totalInserted++; + } + } + } catch (err) { + console.error("❌ [2단계 저장] 매핑 저장 실패:", err); + continue; + } + + if (!mappingId) { + console.error("❌ [2단계 저장] mapping_id 획득 실패 - item:", mappingData.item_id); + continue; + } + + // Step 2: 디테일 테이블에 가격 레코드 저장 + for (const detailTable of detailTables) { + const detailGroups = groupsByTable.get(detailTable) || []; + const priceRecords: Record[] = []; + + detailGroups.forEach((group) => { + const entries = item.fieldGroups[group.id] || []; + const groupFields = additionalFields.filter((f) => f.groupId === group.id); + + entries.forEach((entry) => { + // 실제 값이 있는 엔트리만 저장 + const hasValues = groupFields.some((field) => { + const value = entry[field.name]; + return value !== undefined && value !== null && value !== ""; + }); + + if (hasValues) { + const priceRecord: Record = { + mapping_id: mappingId, + // 비정규화: 직접 필터링을 위해 customer_id, item_id 포함 + ...parentKeys, + item_id: mappingData.item_id, + }; + groupFields.forEach((field) => { + if (entry[field.name] !== undefined) { + priceRecord[field.name] = entry[field.name]; + } + }); + priceRecords.push(priceRecord); + } + }); + }); + + if (priceRecords.length > 0) { + console.log(`📋 [2단계 저장] Step 2 - ${detailTable} 레코드:`, { + mappingId, + count: priceRecords.length, + records: priceRecords, + }); + + const detailResult = await dataApi.upsertGroupedRecords( + detailTable, + { mapping_id: mappingId }, + priceRecords, + ); + + if (detailResult.success) { + console.log(`✅ [2단계 저장] ${detailTable} 저장 성공:`, detailResult); + } else { + console.error(`❌ [2단계 저장] ${detailTable} 저장 실패:`, detailResult.error); + } + } else { + console.log(`⏭️ [2단계 저장] ${detailTable} - 가격 레코드 없음 (빈 항목)`); + } + } + } + + console.log("✅ [SelectedItemsDetailInput] 2단계 저장 완료:", { + inserted: totalInserted, + updated: totalUpdated, }); - // 저장 성공 이벤트 발생 + // 저장 성공 이벤트 window.dispatchEvent( new CustomEvent("formSaveSuccess", { detail: { message: "데이터가 저장되었습니다." }, }), ); } else { - console.error("❌ [SelectedItemsDetailInput] UPSERT 실패:", result.error); - window.dispatchEvent( - new CustomEvent("formSaveError", { - detail: { message: result.error || "데이터 저장 실패" }, - }), - ); + // ============================================================ + // 단일 테이블 저장 (기존 로직 - detailTable 없는 경우) + // ============================================================ + const records = generateCartesianProduct(items); + + console.log("📦 [SelectedItemsDetailInput] 단일 테이블 UPSERT:", { + tableName: mainTable, + parentKeys, + recordCount: records.length, + }); + + const result = await dataApi.upsertGroupedRecords(mainTable, parentKeys, records); + + if (result.success) { + console.log("✅ [SelectedItemsDetailInput] UPSERT 성공:", { + inserted: result.inserted, + updated: result.updated, + deleted: result.deleted, + }); + + window.dispatchEvent( + new CustomEvent("formSaveSuccess", { + detail: { message: "데이터가 저장되었습니다." }, + }), + ); + } else { + console.error("❌ [SelectedItemsDetailInput] UPSERT 실패:", result.error); + window.dispatchEvent( + new CustomEvent("formSaveError", { + detail: { message: result.error || "데이터 저장 실패" }, + }), + ); + } } } catch (error) { console.error("❌ [SelectedItemsDetailInput] UPSERT 오류:", error); @@ -769,7 +948,7 @@ export const SelectedItemsDetailInputComponent: React.FC { window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener); }; - }, [items, component.id, onFormDataChange, componentConfig, formData, generateCartesianProduct]); + }, [items, component.id, onFormDataChange, componentConfig, formData, generateCartesianProduct, dataRegistry]); // 스타일 계산 const componentStyle: React.CSSProperties = { @@ -844,15 +1023,27 @@ export const SelectedItemsDetailInputComponent: React.FC { - const config = AGENT_CONFIGS[agentType]; - - // 모델 선택: PM은 opus, 나머지는 sonnet - const model = agentType === 'pm' ? 'opus-4.5' : 'sonnet-4.5'; - - logger.info(`Calling ${agentType} agent via CLI (spawn)`, { model, task: task.substring(0, 100) }); +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} - const userMessage = context - ? `${task}\n\n배경 정보:\n${context}` - : task; - - const fullPrompt = `${config.systemPrompt}\n\n---\n\n${userMessage}`; +/** + * Cursor Agent CLI 단일 호출 (내부용) + * spawn + stdin 직접 전달 + */ +function spawnAgentOnce( + agentType: AgentType, + fullPrompt: string, + model: string +): Promise { const agentPath = isWindows ? 'agent' : `${process.env.HOME}/.local/bin/agent`; return new Promise((resolve, reject) => { @@ -93,7 +82,6 @@ async function callAgentCLI( child.on('error', (err: Error) => { if (!settled) { settled = true; - logger.error(`${agentType} agent spawn error`, err); reject(err); } }); @@ -103,7 +91,6 @@ async function callAgentCLI( settled = true; if (stderr) { - // 경고/정보 레벨 stderr는 무시 const significantStderr = stderr .split('\n') .filter((line: string) => line && !line.includes('warning') && !line.includes('info') && !line.includes('debug')) @@ -114,13 +101,11 @@ async function callAgentCLI( } if (code === 0 || stdout.trim().length > 0) { - // 정상 종료이거나, 에러 코드여도 stdout에 결과가 있으면 성공 처리 - logger.info(`${agentType} agent completed via CLI (exit code: ${code})`); resolve(stdout.trim()); } else { - const errorMsg = `Agent exited with code ${code}. stderr: ${stderr.substring(0, 1000)}`; - logger.error(`${agentType} agent CLI error`, { code, stderr: stderr.substring(0, 1000) }); - reject(new Error(errorMsg)); + reject(new Error( + `Agent exited with code ${code}. stderr: ${stderr.substring(0, 1000)}` + )); } }); @@ -129,22 +114,69 @@ async function callAgentCLI( if (!settled) { settled = true; child.kill('SIGTERM'); - logger.error(`${agentType} agent timed out after 5 minutes`); reject(new Error(`${agentType} agent timed out after 5 minutes`)); } }, 300000); - // 프로세스 종료 시 타이머 클리어 child.on('close', () => clearTimeout(timeout)); - // stdin으로 프롬프트 직접 전달 (쉘 이스케이프 문제 없음!) + // stdin으로 프롬프트 직접 전달 child.stdin.write(fullPrompt); child.stdin.end(); - - logger.debug(`Prompt sent to ${agentType} agent via stdin (${fullPrompt.length} chars)`); }); } +/** + * Cursor Agent CLI를 통해 에이전트 호출 (재시도 포함) + * + * - 최대 2회 재시도 (총 3회 시도) + * - 재시도 간 2초 대기 (Cursor CLI 동시 실행 제한 대응) + */ +async function callAgentCLI( + agentType: AgentType, + task: string, + context?: string +): Promise { + const config = AGENT_CONFIGS[agentType]; + const model = agentType === 'pm' ? 'opus-4.5' : 'sonnet-4.5'; + const maxRetries = 2; + + logger.info(`Calling ${agentType} agent via CLI (spawn+retry)`, { + model, + task: task.substring(0, 100), + }); + + const userMessage = context + ? `${task}\n\n배경 정보:\n${context}` + : task; + const fullPrompt = `${config.systemPrompt}\n\n---\n\n${userMessage}`; + + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + if (attempt > 0) { + const delay = attempt * 2000; // 2초, 4초 + logger.info(`${agentType} agent retry ${attempt}/${maxRetries} (waiting ${delay}ms)`); + await sleep(delay); + } + + const result = await spawnAgentOnce(agentType, fullPrompt, model); + logger.info(`${agentType} agent completed (attempt ${attempt + 1})`); + return result; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + logger.warn(`${agentType} agent attempt ${attempt + 1} failed`, { + error: lastError.message.substring(0, 200), + }); + } + } + + // 모든 재시도 실패 + logger.error(`${agentType} agent failed after ${maxRetries + 1} attempts`); + throw lastError!; +} + /** * 도구 목록 핸들러 */ @@ -310,12 +342,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }>; }; - logger.info(`Parallel ask to ${requests.length} agents (TRUE PARALLEL!)`); + logger.info(`Parallel ask to ${requests.length} agents (STAGGERED PARALLEL)`); + + // 시차 병렬 실행: 각 에이전트를 500ms 간격으로 시작 + // Cursor Agent CLI 동시 실행 제한 대응 + const STAGGER_DELAY = 500; // ms - // 진짜 병렬 실행! 모든 에이전트가 동시에 작업 const results: ParallelResult[] = await Promise.all( - requests.map(async (req) => { + requests.map(async (req, index) => { try { + // 시차 적용 (첫 번째는 즉시, 이후 500ms 간격) + if (index > 0) { + await sleep(index * STAGGER_DELAY); + } const result = await callAgentCLI(req.agent, req.task, req.context); return { agent: req.agent, result }; } catch (error) {