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.
This commit is contained in:
parent
bb4d90fd58
commit
2e500f066f
|
|
@ -1,7 +1,17 @@
|
||||||
"use client";
|
"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 { 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 { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||||
|
|
@ -67,6 +77,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
// 화면 리셋 키 (컴포넌트 강제 리마운트용)
|
// 화면 리셋 키 (컴포넌트 강제 리마운트용)
|
||||||
const [resetKey, setResetKey] = useState(0);
|
const [resetKey, setResetKey] = useState(0);
|
||||||
|
|
||||||
|
// 모달 닫기 확인 다이얼로그 표시 상태
|
||||||
|
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
|
||||||
|
|
||||||
// localStorage에서 연속 모드 상태 복원
|
// localStorage에서 연속 모드 상태 복원
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
||||||
|
|
@ -218,10 +231,33 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
const parentDataMapping = splitPanelContext?.parentDataMapping || [];
|
const parentDataMapping = splitPanelContext?.parentDataMapping || [];
|
||||||
|
|
||||||
// 부모 데이터 소스
|
// 부모 데이터 소스
|
||||||
const rawParentData =
|
// 🔧 수정: 여러 소스를 병합 (우선순위: splitPanelParentData > selectedLeftData > 기존 formData의 링크 필드)
|
||||||
splitPanelParentData && Object.keys(splitPanelParentData).length > 0
|
// 예: screen 150→226→227 전환 시:
|
||||||
? splitPanelParentData
|
// - splitPanelParentData: item_info 데이터 (screen 226에서 전달)
|
||||||
: splitPanelContext?.selectedLeftData || {};
|
// - 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<string, any> = {};
|
||||||
|
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<string, any> = {};
|
const parentData: Record<string, any> = {};
|
||||||
|
|
@ -495,14 +531,31 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
// 사용자가 바깥 클릭/ESC/X 버튼으로 닫으려 할 때 확인 다이얼로그 표시
|
||||||
// 🔧 URL 파라미터 제거 (mode, editId, tableName 등)
|
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") {
|
if (typeof window !== "undefined") {
|
||||||
const currentUrl = new URL(window.location.href);
|
const currentUrl = new URL(window.location.href);
|
||||||
currentUrl.searchParams.delete("mode");
|
currentUrl.searchParams.delete("mode");
|
||||||
currentUrl.searchParams.delete("editId");
|
currentUrl.searchParams.delete("editId");
|
||||||
currentUrl.searchParams.delete("tableName");
|
currentUrl.searchParams.delete("tableName");
|
||||||
currentUrl.searchParams.delete("groupByColumns");
|
currentUrl.searchParams.delete("groupByColumns");
|
||||||
|
currentUrl.searchParams.delete("dataSourceId");
|
||||||
window.history.pushState({}, "", currentUrl.toString());
|
window.history.pushState({}, "", currentUrl.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -514,8 +567,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
});
|
});
|
||||||
setScreenData(null);
|
setScreenData(null);
|
||||||
setFormData({}); // 폼 데이터 초기화
|
setFormData({}); // 폼 데이터 초기화
|
||||||
|
setOriginalData(null); // 원본 데이터 초기화
|
||||||
|
setSelectedData([]); // 선택된 데이터 초기화
|
||||||
|
setContinuousMode(false);
|
||||||
|
localStorage.setItem("screenModal_continuousMode", "false");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 기존 handleClose를 유지 (이벤트 핸들러 등에서 사용)
|
||||||
|
const handleClose = handleCloseInternal;
|
||||||
|
|
||||||
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터
|
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터
|
||||||
const getModalStyle = () => {
|
const getModalStyle = () => {
|
||||||
if (!screenDimensions) {
|
if (!screenDimensions) {
|
||||||
|
|
@ -615,10 +675,28 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
<Dialog
|
||||||
|
open={modalState.isOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
// X 버튼 클릭 시에도 확인 다이얼로그 표시
|
||||||
|
if (!open) {
|
||||||
|
handleCloseAttempt();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className={`${modalStyle.className} ${className || ""} max-w-none flex flex-col`}
|
className={`${modalStyle.className} ${className || ""} max-w-none flex flex-col`}
|
||||||
{...(modalStyle.style && { style: modalStyle.style })}
|
{...(modalStyle.style && { style: modalStyle.style })}
|
||||||
|
// 바깥 클릭 시 바로 닫히지 않도록 방지
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCloseAttempt();
|
||||||
|
}}
|
||||||
|
// ESC 키 누를 때도 바로 닫히지 않도록 방지
|
||||||
|
onEscapeKeyDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCloseAttempt();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -838,6 +916,36 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
|
{/* 모달 닫기 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={showCloseConfirm} onOpenChange={setShowCloseConfirm}>
|
||||||
|
<AlertDialogContent className="!z-[1100] max-w-[95vw] sm:max-w-[400px]">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-base sm:text-lg">
|
||||||
|
화면을 닫으시겠습니까?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||||
|
지금 나가시면 진행 중인 데이터가 저장되지 않습니다.
|
||||||
|
<br />
|
||||||
|
계속 작업하시려면 '계속 작업' 버튼을 눌러주세요.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<AlertDialogCancel
|
||||||
|
onClick={handleCancelClose}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
계속 작업
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleConfirmClose}
|
||||||
|
className="h-8 flex-1 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
나가기
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -490,22 +490,25 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
const allGroupsEmpty = groupEntriesArrays.every((arr) => arr.length === 0);
|
const allGroupsEmpty = groupEntriesArrays.every((arr) => arr.length === 0);
|
||||||
|
|
||||||
if (allGroupsEmpty) {
|
if (allGroupsEmpty) {
|
||||||
// 🔧 아이템이 1개뿐이면 기본 레코드 생성 (첫 저장 시)
|
// 디테일 데이터가 없어도 기본 레코드 생성 (품목-거래처 매핑 유지)
|
||||||
// 아이템이 여러 개면 빈 아이템은 건너뛰기 (불필요한 NULL 레코드 방지)
|
// autoFillFrom 필드 (item_id 등)는 반드시 포함시켜야 나중에 식별 가능
|
||||||
if (itemsList.length === 1) {
|
const baseRecord: Record<string, any> = {};
|
||||||
console.log("📝 [generateCartesianProduct] 단일 아이템, 모든 그룹 비어있음 - 기본 레코드 생성", {
|
additionalFields.forEach((f) => {
|
||||||
itemIndex,
|
if (f.autoFillFrom && item.originalData) {
|
||||||
itemId: item.id,
|
const value = item.originalData[f.autoFillFrom];
|
||||||
});
|
if (value !== undefined && value !== null) {
|
||||||
// 빈 객체를 추가하면 parentKeys와 합쳐져서 기본 레코드가 됨
|
baseRecord[f.name] = value;
|
||||||
allRecords.push({});
|
}
|
||||||
} else {
|
}
|
||||||
console.log("⏭️ [generateCartesianProduct] 다중 아이템 중 빈 아이템 - 건너뜀", {
|
});
|
||||||
itemIndex,
|
|
||||||
itemId: item.id,
|
console.log("📝 [generateCartesianProduct] 모든 그룹 비어있음 - 기본 레코드 생성 (매핑 유지)", {
|
||||||
totalItems: itemsList.length,
|
itemIndex,
|
||||||
});
|
itemId: item.id,
|
||||||
}
|
baseRecord,
|
||||||
|
totalItems: itemsList.length,
|
||||||
|
});
|
||||||
|
allRecords.push(baseRecord);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -579,17 +582,15 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 수정 모드인지 확인 (URL에 mode=edit이 있으면)
|
// parentDataMapping이 있으면 UPSERT API로 직접 저장 (생성/수정 모드 무관)
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const hasParentMapping = componentConfig.parentDataMapping && componentConfig.parentDataMapping.length > 0;
|
||||||
const mode = urlParams.get("mode");
|
|
||||||
const isEditMode = mode === "edit";
|
|
||||||
|
|
||||||
console.log("📝 [SelectedItemsDetailInput] 저장 모드:", { mode, isEditMode });
|
console.log("📝 [SelectedItemsDetailInput] 저장 모드:", { hasParentMapping });
|
||||||
|
|
||||||
if (isEditMode && componentConfig.parentDataMapping && componentConfig.parentDataMapping.length > 0) {
|
if (hasParentMapping) {
|
||||||
// 🔄 수정 모드: UPSERT API 사용
|
// UPSERT API로 직접 DB 저장
|
||||||
try {
|
try {
|
||||||
console.log("🔄 [SelectedItemsDetailInput] UPSERT 모드로 저장 시작");
|
console.log("🔄 [SelectedItemsDetailInput] UPSERT 저장 시작");
|
||||||
console.log("📋 [SelectedItemsDetailInput] componentConfig:", {
|
console.log("📋 [SelectedItemsDetailInput] componentConfig:", {
|
||||||
targetTable: componentConfig.targetTable,
|
targetTable: componentConfig.targetTable,
|
||||||
parentDataMapping: componentConfig.parentDataMapping,
|
parentDataMapping: componentConfig.parentDataMapping,
|
||||||
|
|
@ -622,14 +623,30 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
);
|
);
|
||||||
|
|
||||||
componentConfig.parentDataMapping.forEach((mapping) => {
|
componentConfig.parentDataMapping.forEach((mapping) => {
|
||||||
// 🆕 Entity Join 필드도 처리 (예: customer_code -> customer_id_customer_code)
|
// 1차: formData(sourceData)에서 찾기
|
||||||
const value = getFieldValue(sourceData, mapping.sourceField);
|
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) {
|
if (value !== undefined && value !== null) {
|
||||||
parentKeys[mapping.targetField] = value;
|
parentKeys[mapping.targetField] = value;
|
||||||
console.log(`✅ [parentKeys] ${mapping.sourceField} → ${mapping.targetField}:`, value);
|
console.log(`✅ [parentKeys] ${mapping.sourceField} → ${mapping.targetField}:`, value);
|
||||||
} else {
|
} else {
|
||||||
console.warn(
|
console.warn(
|
||||||
`⚠️ [SelectedItemsDetailInput] 부모 키 누락: ${mapping.sourceField} → ${mapping.targetField}`,
|
`⚠️ [SelectedItemsDetailInput] 부모 키 누락: ${mapping.sourceField} → ${mapping.targetField}`,
|
||||||
|
`(sourceData, dataRegistry["${mapping.sourceTable}"] 모두 확인)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -657,15 +674,6 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// items를 Cartesian Product로 변환
|
|
||||||
const records = generateCartesianProduct(items);
|
|
||||||
|
|
||||||
console.log("📦 [SelectedItemsDetailInput] UPSERT 레코드:", {
|
|
||||||
parentKeys,
|
|
||||||
recordCount: records.length,
|
|
||||||
records,
|
|
||||||
});
|
|
||||||
|
|
||||||
// targetTable 검증
|
// targetTable 검증
|
||||||
if (!componentConfig.targetTable) {
|
if (!componentConfig.targetTable) {
|
||||||
console.error("❌ [SelectedItemsDetailInput] targetTable이 설정되지 않았습니다!");
|
console.error("❌ [SelectedItemsDetailInput] targetTable이 설정되지 않았습니다!");
|
||||||
|
|
@ -674,57 +682,228 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
detail: { message: "대상 테이블이 설정되지 않았습니다." },
|
detail: { message: "대상 테이블이 설정되지 않았습니다." },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🔧 기본 저장 건너뛰기 - event.detail 객체 직접 수정
|
|
||||||
if (event instanceof CustomEvent && event.detail) {
|
if (event instanceof CustomEvent && event.detail) {
|
||||||
(event.detail as any).skipDefaultSave = true;
|
(event.detail as any).skipDefaultSave = true;
|
||||||
console.log("🚫 [SelectedItemsDetailInput] skipDefaultSave = true (targetTable 없음)");
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔧 먼저 기본 저장 로직 건너뛰기 설정 (UPSERT 전에!)
|
// 🔧 기본 저장 건너뛰기 설정 (UPSERT 전에!)
|
||||||
// buttonActions.ts에서 beforeSaveEventDetail 객체를 event.detail로 전달하므로 직접 수정 가능
|
|
||||||
if (event instanceof CustomEvent && event.detail) {
|
if (event instanceof CustomEvent && event.detail) {
|
||||||
(event.detail as any).skipDefaultSave = true;
|
(event.detail as any).skipDefaultSave = true;
|
||||||
console.log("🚫 [SelectedItemsDetailInput] skipDefaultSave = true (UPSERT 처리)", event.detail);
|
console.log("🚫 [SelectedItemsDetailInput] skipDefaultSave = true (UPSERT 처리)");
|
||||||
} else {
|
|
||||||
console.error("❌ [SelectedItemsDetailInput] event.detail이 없습니다! 기본 저장이 실행될 수 있습니다.", event);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🎯 [SelectedItemsDetailInput] targetTable:", componentConfig.targetTable);
|
const { dataApi } = await import("@/lib/api/data");
|
||||||
console.log("📡 [SelectedItemsDetailInput] UPSERT API 호출 직전:", {
|
const groups = componentConfig.fieldGroups || [];
|
||||||
tableName: componentConfig.targetTable,
|
const additionalFields = componentConfig.additionalFields || [];
|
||||||
tableNameType: typeof componentConfig.targetTable,
|
const mainTable = componentConfig.targetTable!;
|
||||||
tableNameLength: componentConfig.targetTable?.length,
|
|
||||||
parentKeys,
|
// fieldGroup별 sourceTable 분류
|
||||||
recordsCount: records.length,
|
const groupsByTable = new Map<string, typeof groups>();
|
||||||
|
groups.forEach((group) => {
|
||||||
|
const table = group.sourceTable || mainTable;
|
||||||
|
if (!groupsByTable.has(table)) {
|
||||||
|
groupsByTable.set(table, []);
|
||||||
|
}
|
||||||
|
groupsByTable.get(table)!.push(group);
|
||||||
});
|
});
|
||||||
|
|
||||||
// UPSERT API 호출
|
// 디테일 테이블이 있는지 확인 (mainTable과 다른 sourceTable)
|
||||||
const { dataApi } = await import("@/lib/api/data");
|
const detailTables = [...groupsByTable.keys()].filter((t) => t !== mainTable);
|
||||||
const result = await dataApi.upsertGroupedRecords(componentConfig.targetTable, parentKeys, records);
|
const hasDetailTable = detailTables.length > 0;
|
||||||
|
|
||||||
if (result.success) {
|
console.log("🏗️ [SelectedItemsDetailInput] 저장 구조:", {
|
||||||
console.log("✅ [SelectedItemsDetailInput] UPSERT 성공:", {
|
mainTable,
|
||||||
inserted: result.inserted,
|
detailTables,
|
||||||
updated: result.updated,
|
hasDetailTable,
|
||||||
deleted: result.deleted,
|
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<string, any> = { ...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<string, any> = {};
|
||||||
|
|
||||||
|
// 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<string, any>[] = [];
|
||||||
|
|
||||||
|
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<string, any> = {
|
||||||
|
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(
|
window.dispatchEvent(
|
||||||
new CustomEvent("formSaveSuccess", {
|
new CustomEvent("formSaveSuccess", {
|
||||||
detail: { message: "데이터가 저장되었습니다." },
|
detail: { message: "데이터가 저장되었습니다." },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.error("❌ [SelectedItemsDetailInput] UPSERT 실패:", result.error);
|
// ============================================================
|
||||||
window.dispatchEvent(
|
// 단일 테이블 저장 (기존 로직 - detailTable 없는 경우)
|
||||||
new CustomEvent("formSaveError", {
|
// ============================================================
|
||||||
detail: { message: result.error || "데이터 저장 실패" },
|
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) {
|
} catch (error) {
|
||||||
console.error("❌ [SelectedItemsDetailInput] UPSERT 오류:", error);
|
console.error("❌ [SelectedItemsDetailInput] UPSERT 오류:", error);
|
||||||
|
|
@ -769,7 +948,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
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 = {
|
const componentStyle: React.CSSProperties = {
|
||||||
|
|
@ -844,15 +1023,27 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
const unitMatch = roundingTypeLabel.match(/(\d+)/);
|
const unitMatch = roundingTypeLabel.match(/(\d+)/);
|
||||||
const unit = unitMatch ? parseInt(unitMatch[1]) : parseFloat(roundingTypeCode) || 1;
|
const unit = unitMatch ? parseInt(unitMatch[1]) : parseFloat(roundingTypeCode) || 1;
|
||||||
|
|
||||||
|
const priceBeforeRounding = price;
|
||||||
|
|
||||||
// roundingUnit 라벨로 반올림 방법 결정
|
// roundingUnit 라벨로 반올림 방법 결정
|
||||||
if (roundingUnitLabel.includes("반올림") && !roundingUnitLabel.includes("없음")) {
|
if (roundingUnitLabel.includes("없음") || !roundingUnitCode) {
|
||||||
price = Math.round(price / unit) * unit;
|
// 반올림없음: 할인 적용된 원래 값 그대로
|
||||||
|
// price 변경 없음
|
||||||
} else if (roundingUnitLabel.includes("절삭")) {
|
} else if (roundingUnitLabel.includes("절삭")) {
|
||||||
price = Math.floor(price / unit) * unit;
|
price = Math.floor(price / unit) * unit;
|
||||||
} else if (roundingUnitLabel.includes("올림")) {
|
} else if (roundingUnitLabel.includes("올림")) {
|
||||||
price = Math.ceil(price / unit) * unit;
|
price = Math.ceil(price / unit) * unit;
|
||||||
|
} else if (roundingUnitLabel.includes("반올림")) {
|
||||||
|
price = Math.round(price / unit) * unit;
|
||||||
}
|
}
|
||||||
// "반올림없음"이면 그대로
|
|
||||||
|
console.log("🔢 [calculatePrice] 반올림 처리:", {
|
||||||
|
roundingTypeLabel,
|
||||||
|
roundingUnitLabel,
|
||||||
|
unit,
|
||||||
|
priceBeforeRounding,
|
||||||
|
priceAfterRounding: price,
|
||||||
|
});
|
||||||
|
|
||||||
return price;
|
return price;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -40,32 +40,21 @@ const server = new Server(
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cursor Agent CLI를 통해 에이전트 호출
|
* 유틸: ms만큼 대기
|
||||||
* Cursor Team Plan 사용 - API 키 불필요!
|
|
||||||
*
|
|
||||||
* spawn + stdin 직접 전달 방식으로 쉘 이스케이프 문제 완전 해결
|
|
||||||
*
|
|
||||||
* 크로스 플랫폼 지원:
|
|
||||||
* - Windows: agent (PATH에서 검색)
|
|
||||||
* - Mac/Linux: ~/.local/bin/agent
|
|
||||||
*/
|
*/
|
||||||
async function callAgentCLI(
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cursor Agent CLI 단일 호출 (내부용)
|
||||||
|
* spawn + stdin 직접 전달
|
||||||
|
*/
|
||||||
|
function spawnAgentOnce(
|
||||||
agentType: AgentType,
|
agentType: AgentType,
|
||||||
task: string,
|
fullPrompt: string,
|
||||||
context?: string
|
model: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
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) });
|
|
||||||
|
|
||||||
const userMessage = context
|
|
||||||
? `${task}\n\n배경 정보:\n${context}`
|
|
||||||
: task;
|
|
||||||
|
|
||||||
const fullPrompt = `${config.systemPrompt}\n\n---\n\n${userMessage}`;
|
|
||||||
const agentPath = isWindows ? 'agent' : `${process.env.HOME}/.local/bin/agent`;
|
const agentPath = isWindows ? 'agent' : `${process.env.HOME}/.local/bin/agent`;
|
||||||
|
|
||||||
return new Promise<string>((resolve, reject) => {
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
|
@ -93,7 +82,6 @@ async function callAgentCLI(
|
||||||
child.on('error', (err: Error) => {
|
child.on('error', (err: Error) => {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
settled = true;
|
settled = true;
|
||||||
logger.error(`${agentType} agent spawn error`, err);
|
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -103,7 +91,6 @@ async function callAgentCLI(
|
||||||
settled = true;
|
settled = true;
|
||||||
|
|
||||||
if (stderr) {
|
if (stderr) {
|
||||||
// 경고/정보 레벨 stderr는 무시
|
|
||||||
const significantStderr = stderr
|
const significantStderr = stderr
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.filter((line: string) => line && !line.includes('warning') && !line.includes('info') && !line.includes('debug'))
|
.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) {
|
if (code === 0 || stdout.trim().length > 0) {
|
||||||
// 정상 종료이거나, 에러 코드여도 stdout에 결과가 있으면 성공 처리
|
|
||||||
logger.info(`${agentType} agent completed via CLI (exit code: ${code})`);
|
|
||||||
resolve(stdout.trim());
|
resolve(stdout.trim());
|
||||||
} else {
|
} else {
|
||||||
const errorMsg = `Agent exited with code ${code}. stderr: ${stderr.substring(0, 1000)}`;
|
reject(new Error(
|
||||||
logger.error(`${agentType} agent CLI error`, { code, stderr: stderr.substring(0, 1000) });
|
`Agent exited with code ${code}. stderr: ${stderr.substring(0, 1000)}`
|
||||||
reject(new Error(errorMsg));
|
));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -129,22 +114,69 @@ async function callAgentCLI(
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
settled = true;
|
settled = true;
|
||||||
child.kill('SIGTERM');
|
child.kill('SIGTERM');
|
||||||
logger.error(`${agentType} agent timed out after 5 minutes`);
|
|
||||||
reject(new Error(`${agentType} agent timed out after 5 minutes`));
|
reject(new Error(`${agentType} agent timed out after 5 minutes`));
|
||||||
}
|
}
|
||||||
}, 300000);
|
}, 300000);
|
||||||
|
|
||||||
// 프로세스 종료 시 타이머 클리어
|
|
||||||
child.on('close', () => clearTimeout(timeout));
|
child.on('close', () => clearTimeout(timeout));
|
||||||
|
|
||||||
// stdin으로 프롬프트 직접 전달 (쉘 이스케이프 문제 없음!)
|
// stdin으로 프롬프트 직접 전달
|
||||||
child.stdin.write(fullPrompt);
|
child.stdin.write(fullPrompt);
|
||||||
child.stdin.end();
|
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<string> {
|
||||||
|
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(
|
const results: ParallelResult[] = await Promise.all(
|
||||||
requests.map(async (req) => {
|
requests.map(async (req, index) => {
|
||||||
try {
|
try {
|
||||||
|
// 시차 적용 (첫 번째는 즉시, 이후 500ms 간격)
|
||||||
|
if (index > 0) {
|
||||||
|
await sleep(index * STAGGER_DELAY);
|
||||||
|
}
|
||||||
const result = await callAgentCLI(req.agent, req.task, req.context);
|
const result = await callAgentCLI(req.agent, req.task, req.context);
|
||||||
return { agent: req.agent, result };
|
return { agent: req.agent, result };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue