Compare commits

...

5 Commits

17 changed files with 821 additions and 354 deletions

View File

@ -50,3 +50,4 @@ router.get("/data/:groupCode", getAutoFillData);
export default router; export default router;

View File

@ -46,3 +46,4 @@ router.get("/filtered-options/:relationCode", getFilteredOptions);
export default router; export default router;

View File

@ -62,3 +62,4 @@ router.get("/:groupCode/options/:levelOrder", getLevelOptions);
export default router; export default router;

View File

@ -50,3 +50,4 @@ router.get("/options/:exclusionCode", getExcludedOptions);
export default router; export default router;

View File

@ -903,7 +903,7 @@ export class DynamicFormService {
return `${key} = $${index + 1}::numeric`; return `${key} = $${index + 1}::numeric`;
} else if (dataType === "boolean") { } else if (dataType === "boolean") {
return `${key} = $${index + 1}::boolean`; return `${key} = $${index + 1}::boolean`;
} else if (dataType === 'jsonb' || dataType === 'json') { } else if (dataType === "jsonb" || dataType === "json") {
// 🆕 JSONB/JSON 타입은 명시적 캐스팅 // 🆕 JSONB/JSON 타입은 명시적 캐스팅
return `${key} = $${index + 1}::jsonb`; return `${key} = $${index + 1}::jsonb`;
} else { } else {
@ -919,7 +919,11 @@ export class DynamicFormService {
const dataType = columnTypes[key]; const dataType = columnTypes[key];
// JSONB/JSON 타입이고 배열/객체인 경우 JSON 문자열로 변환 // JSONB/JSON 타입이고 배열/객체인 경우 JSON 문자열로 변환
if ((dataType === 'jsonb' || dataType === 'json') && (Array.isArray(value) || (typeof value === 'object' && value !== null))) { if (
(dataType === "jsonb" || dataType === "json") &&
(Array.isArray(value) ||
(typeof value === "object" && value !== null))
) {
return JSON.stringify(value); return JSON.stringify(value);
} }
return value; return value;
@ -1588,6 +1592,7 @@ export class DynamicFormService {
/** /**
* ( ) * ( )
*
*/ */
private async executeDataflowControlIfConfigured( private async executeDataflowControlIfConfigured(
screenId: number, screenId: number,
@ -1629,105 +1634,67 @@ export class DynamicFormService {
hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig, hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig,
hasDiagramId: hasDiagramId:
!!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId, !!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId,
hasFlowControls:
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
}); });
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우 // 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
if ( if (
properties?.componentType === "button-primary" && properties?.componentType === "button-primary" &&
properties?.componentConfig?.action?.type === "save" && properties?.componentConfig?.action?.type === "save" &&
properties?.webTypeConfig?.enableDataflowControl === true && properties?.webTypeConfig?.enableDataflowControl === true
properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId
) { ) {
controlConfigFound = true; const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
const diagramId =
properties.webTypeConfig.dataflowConfig.selectedDiagramId;
const relationshipId =
properties.webTypeConfig.dataflowConfig.selectedRelationshipId;
console.log(`🎯 제어관리 설정 발견:`, { // 다중 제어 설정 확인 (flowControls 배열)
componentId: layout.component_id, const flowControls = dataflowConfig?.flowControls || [];
diagramId,
relationshipId,
triggerType,
});
// 노드 플로우 실행 (relationshipId가 없는 경우 노드 플로우로 간주) // flowControls가 있으면 다중 제어 실행, 없으면 기존 단일 제어 실행
let controlResult: any; if (flowControls.length > 0) {
controlConfigFound = true;
console.log(`🎯 다중 제어관리 설정 발견: ${flowControls.length}`);
if (!relationshipId) { // 순서대로 정렬
// 노드 플로우 실행 const sortedControls = [...flowControls].sort(
console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`); (a: any, b: any) => (a.order || 0) - (b.order || 0)
const { NodeFlowExecutionService } = await import(
"./nodeFlowExecutionService"
); );
const executionResult = await NodeFlowExecutionService.executeFlow( // 다중 제어 순차 실행
await this.executeMultipleFlowControls(
sortedControls,
savedData,
screenId,
tableName,
triggerType,
userId,
companyCode
);
} else if (dataflowConfig?.selectedDiagramId) {
// 기존 단일 제어 실행 (하위 호환성)
controlConfigFound = true;
const diagramId = dataflowConfig.selectedDiagramId;
const relationshipId = dataflowConfig.selectedRelationshipId;
console.log(`🎯 단일 제어관리 설정 발견:`, {
componentId: layout.component_id,
diagramId, diagramId,
{ relationshipId,
sourceData: [savedData], triggerType,
dataSourceType: "formData", });
buttonId: "save-button",
screenId: screenId,
userId: userId,
companyCode: companyCode,
formData: savedData,
}
);
controlResult = { await this.executeSingleFlowControl(
success: executionResult.success, diagramId,
message: executionResult.message, relationshipId,
executedActions: executionResult.nodes?.map((node) => ({ savedData,
nodeId: node.nodeId, screenId,
status: node.status, tableName,
duration: node.duration, triggerType,
})), userId,
errors: executionResult.nodes companyCode
?.filter((node) => node.status === "failed")
.map((node) => node.error || "실행 실패"),
};
} else {
// 관계 기반 제어관리 실행
console.log(
`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`
); );
controlResult =
await this.dataflowControlService.executeDataflowControl(
diagramId,
relationshipId,
triggerType,
savedData,
tableName,
userId
);
} }
console.log(`🎯 제어관리 실행 결과:`, controlResult); // 첫 번째 설정된 버튼의 제어관리만 실행
if (controlResult.success) {
console.log(`✅ 제어관리 실행 성공: ${controlResult.message}`);
if (
controlResult.executedActions &&
controlResult.executedActions.length > 0
) {
console.log(`📊 실행된 액션들:`, controlResult.executedActions);
}
// 오류가 있는 경우 경고 로그 출력 (성공이지만 일부 액션 실패)
if (controlResult.errors && controlResult.errors.length > 0) {
console.warn(
`⚠️ 제어관리 실행 중 일부 오류 발생:`,
controlResult.errors
);
// 오류 정보를 별도로 저장하여 필요시 사용자에게 알림 가능
// 현재는 로그만 출력하고 메인 저장 프로세스는 계속 진행
}
} else {
console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`);
// 제어관리 실패는 메인 저장 프로세스에 영향을 주지 않음
}
// 첫 번째 설정된 제어관리만 실행 (여러 개가 있을 경우)
break; break;
} }
} }
@ -1741,6 +1708,218 @@ export class DynamicFormService {
} }
} }
/**
*
*/
private async executeMultipleFlowControls(
flowControls: Array<{
id: string;
flowId: number;
flowName: string;
executionTiming: string;
order: number;
}>,
savedData: Record<string, any>,
screenId: number,
tableName: string,
triggerType: "insert" | "update" | "delete",
userId: string,
companyCode: string
): Promise<void> {
console.log(`🚀 다중 제어 순차 실행 시작: ${flowControls.length}`);
const { NodeFlowExecutionService } = await import(
"./nodeFlowExecutionService"
);
const results: Array<{
order: number;
flowId: number;
flowName: string;
success: boolean;
message: string;
duration: number;
}> = [];
for (let i = 0; i < flowControls.length; i++) {
const control = flowControls[i];
const startTime = Date.now();
console.log(
`\n📍 [${i + 1}/${flowControls.length}] 제어 실행: ${control.flowName} (flowId: ${control.flowId})`
);
try {
// 유효하지 않은 flowId 스킵
if (!control.flowId || control.flowId <= 0) {
console.warn(`⚠️ 유효하지 않은 flowId, 스킵: ${control.flowId}`);
results.push({
order: control.order,
flowId: control.flowId,
flowName: control.flowName,
success: false,
message: "유효하지 않은 flowId",
duration: 0,
});
continue;
}
const executionResult = await NodeFlowExecutionService.executeFlow(
control.flowId,
{
sourceData: [savedData],
dataSourceType: "formData",
buttonId: "save-button",
screenId: screenId,
userId: userId,
companyCode: companyCode,
formData: savedData,
}
);
const duration = Date.now() - startTime;
results.push({
order: control.order,
flowId: control.flowId,
flowName: control.flowName,
success: executionResult.success,
message: executionResult.message,
duration,
});
if (executionResult.success) {
console.log(
`✅ [${i + 1}/${flowControls.length}] 제어 성공: ${control.flowName} (${duration}ms)`
);
} else {
console.error(
`❌ [${i + 1}/${flowControls.length}] 제어 실패: ${control.flowName} - ${executionResult.message}`
);
// 이전 제어 실패 시 다음 제어 실행 중단
console.warn(`⚠️ 이전 제어 실패로 인해 나머지 제어 실행 중단`);
break;
}
} catch (error: any) {
const duration = Date.now() - startTime;
console.error(
`❌ [${i + 1}/${flowControls.length}] 제어 실행 오류: ${control.flowName}`,
error
);
results.push({
order: control.order,
flowId: control.flowId,
flowName: control.flowName,
success: false,
message: error.message || "실행 오류",
duration,
});
// 오류 발생 시 다음 제어 실행 중단
console.warn(`⚠️ 제어 실행 오류로 인해 나머지 제어 실행 중단`);
break;
}
}
// 실행 결과 요약
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
console.log(`\n📊 다중 제어 실행 완료:`, {
total: flowControls.length,
executed: results.length,
success: successCount,
failed: failCount,
totalDuration: `${totalDuration}ms`,
});
}
/**
* ( , )
*/
private async executeSingleFlowControl(
diagramId: number,
relationshipId: string | null,
savedData: Record<string, any>,
screenId: number,
tableName: string,
triggerType: "insert" | "update" | "delete",
userId: string,
companyCode: string
): Promise<void> {
let controlResult: any;
if (!relationshipId) {
// 노드 플로우 실행
console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`);
const { NodeFlowExecutionService } = await import(
"./nodeFlowExecutionService"
);
const executionResult = await NodeFlowExecutionService.executeFlow(
diagramId,
{
sourceData: [savedData],
dataSourceType: "formData",
buttonId: "save-button",
screenId: screenId,
userId: userId,
companyCode: companyCode,
formData: savedData,
}
);
controlResult = {
success: executionResult.success,
message: executionResult.message,
executedActions: executionResult.nodes?.map((node) => ({
nodeId: node.nodeId,
status: node.status,
duration: node.duration,
})),
errors: executionResult.nodes
?.filter((node) => node.status === "failed")
.map((node) => node.error || "실행 실패"),
};
} else {
// 관계 기반 제어관리 실행
console.log(
`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`
);
controlResult = await this.dataflowControlService.executeDataflowControl(
diagramId,
relationshipId,
triggerType,
savedData,
tableName,
userId
);
}
console.log(`🎯 제어관리 실행 결과:`, controlResult);
if (controlResult.success) {
console.log(`✅ 제어관리 실행 성공: ${controlResult.message}`);
if (
controlResult.executedActions &&
controlResult.executedActions.length > 0
) {
console.log(`📊 실행된 액션들:`, controlResult.executedActions);
}
if (controlResult.errors && controlResult.errors.length > 0) {
console.warn(
`⚠️ 제어관리 실행 중 일부 오류 발생:`,
controlResult.errors
);
}
} else {
console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`);
}
}
/** /**
* *
* ( ) * ( )

View File

@ -582,3 +582,4 @@ const result = await executeNodeFlow(flowId, {

View File

@ -355,3 +355,4 @@
- [ ] 부모 화면에서 모달로 데이터가 전달되는가? - [ ] 부모 화면에서 모달로 데이터가 전달되는가?
- [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가? - [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가?

View File

@ -118,7 +118,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 전역 모달 이벤트 리스너 // 전역 모달 이벤트 리스너
useEffect(() => { useEffect(() => {
const handleOpenEditModal = (event: CustomEvent) => { const handleOpenEditModal = (event: CustomEvent) => {
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode } = event.detail; const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode } =
event.detail;
setModalState({ setModalState({
isOpen: true, isOpen: true,
@ -136,7 +137,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
setFormData(editData || {}); setFormData(editData || {});
// 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드) // 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드)
// originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨 // originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨
setOriginalData(isCreateMode ? {} : (editData || {})); setOriginalData(isCreateMode ? {} : editData || {});
if (isCreateMode) { if (isCreateMode) {
console.log("[EditModal] 생성 모드로 열림, 초기값:", editData); console.log("[EditModal] 생성 모드로 열림, 초기값:", editData);
@ -464,9 +465,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
for (const currentData of groupData) { for (const currentData of groupData) {
if (currentData.id) { if (currentData.id) {
// id 기반 매칭 (인덱스 기반 X) // id 기반 매칭 (인덱스 기반 X)
const originalItemData = originalGroupData.find( const originalItemData = originalGroupData.find((orig) => orig.id === currentData.id);
(orig) => orig.id === currentData.id
);
if (!originalItemData) { if (!originalItemData) {
console.warn(`원본 데이터를 찾을 수 없습니다 (id: ${currentData.id})`); console.warn(`원본 데이터를 찾을 수 없습니다 (id: ${currentData.id})`);
@ -539,9 +538,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 3⃣ 삭제된 품목 제거 (원본에는 있지만 현재 데이터에는 없는 항목) // 3⃣ 삭제된 품목 제거 (원본에는 있지만 현재 데이터에는 없는 항목)
const currentIds = new Set(groupData.map((item) => item.id).filter(Boolean)); const currentIds = new Set(groupData.map((item) => item.id).filter(Boolean));
const deletedItems = originalGroupData.filter( const deletedItems = originalGroupData.filter((orig) => orig.id && !currentIds.has(orig.id));
(orig) => orig.id && !currentIds.has(orig.id)
);
for (const deletedItem of deletedItems) { for (const deletedItem of deletedItems) {
console.log("🗑️ 품목 삭제:", deletedItem); console.log("🗑️ 품목 삭제:", deletedItem);
@ -549,7 +546,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
try { try {
const response = await dynamicFormApi.deleteFormDataFromTable( const response = await dynamicFormApi.deleteFormDataFromTable(
deletedItem.id, deletedItem.id,
screenData.screenInfo.tableName screenData.screenInfo.tableName,
); );
if (response.success) { if (response.success) {
@ -701,10 +698,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
return ( return (
<Dialog open={modalState.isOpen} onOpenChange={handleClose}> <Dialog open={modalState.isOpen} onOpenChange={handleClose}>
<DialogContent <DialogContent className={`${modalStyle.className} ${className || ""} max-w-none`} style={modalStyle.style}>
className={`${modalStyle.className} ${className || ""} max-w-none`}
style={modalStyle.style}
>
<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">
<DialogTitle className="text-base">{modalState.title || "데이터 수정"}</DialogTitle> <DialogTitle className="text-base">{modalState.title || "데이터 수정"}</DialogTitle>
@ -717,7 +711,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
</div> </div>
</DialogHeader> </DialogHeader>
<div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-transparent"> <div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
{loading ? ( {loading ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="text-center"> <div className="text-center">
@ -751,7 +745,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}, },
}; };
const groupedDataProp = groupData.length > 0 ? groupData : undefined; const groupedDataProp = groupData.length > 0 ? groupData : undefined;
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가 // 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
@ -775,6 +768,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
component={adjustedComponent} component={adjustedComponent}
allComponents={screenData.components} allComponents={screenData.components}
formData={enrichedFormData} formData={enrichedFormData}
originalData={originalData} // 🆕 원본 데이터 전달 (수정 모드에서 UniversalFormModal 초기화용)
onFormDataChange={(fieldName, value) => { onFormDataChange={(fieldName, value) => {
// 🆕 그룹 데이터가 있으면 처리 // 🆕 그룹 데이터가 있으면 처리
if (groupData.length > 0) { if (groupData.length > 0) {
@ -787,14 +781,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
prev.map((item) => ({ prev.map((item) => ({
...item, ...item,
[fieldName]: value, [fieldName]: value,
})) })),
); );
} }
} else { } else {
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
[fieldName]: value, [fieldName]: value,
})); }));
} }
}} }}
screenInfo={{ screenInfo={{

View File

@ -1,11 +1,14 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Settings, Clock, Info, Workflow } from "lucide-react"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Settings, Clock, Info, Workflow, Plus, Trash2, GripVertical, ChevronUp, ChevronDown } from "lucide-react";
import { ComponentData } from "@/types/screen"; import { ComponentData } from "@/types/screen";
import { getNodeFlows, NodeFlow } from "@/lib/api/nodeFlows"; import { getNodeFlows, NodeFlow } from "@/lib/api/nodeFlows";
@ -14,11 +17,22 @@ interface ImprovedButtonControlConfigPanelProps {
onUpdateProperty: (path: string, value: any) => void; onUpdateProperty: (path: string, value: any) => void;
} }
// 다중 제어 설정 인터페이스
interface FlowControlConfig {
id: string;
flowId: number;
flowName: string;
executionTiming: "before" | "after" | "replace";
order: number;
}
/** /**
* 🔥 * 🔥
* *
* : * :
* - * -
* -
* -
*/ */
export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlConfigPanelProps> = ({ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlConfigPanelProps> = ({
component, component,
@ -27,6 +41,9 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
const config = component.webTypeConfig || {}; const config = component.webTypeConfig || {};
const dataflowConfig = config.dataflowConfig || {}; const dataflowConfig = config.dataflowConfig || {};
// 다중 제어 설정 (배열)
const flowControls: FlowControlConfig[] = dataflowConfig.flowControls || [];
// 🔥 State 관리 // 🔥 State 관리
const [flows, setFlows] = useState<NodeFlow[]>([]); const [flows, setFlows] = useState<NodeFlow[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -58,24 +75,118 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
}; };
/** /**
* 🔥 * 🔥
*/ */
const handleFlowSelect = (flowId: string) => { const handleAddControl = useCallback(() => {
const selectedFlow = flows.find((f) => f.flowId.toString() === flowId); const newControl: FlowControlConfig = {
if (selectedFlow) { id: `control_${Date.now()}`,
// 전체 dataflowConfig 업데이트 (selectedDiagramId 포함) flowId: 0,
onUpdateProperty("webTypeConfig.dataflowConfig", { flowName: "",
...dataflowConfig, executionTiming: "after",
selectedDiagramId: selectedFlow.flowId, // 백엔드에서 사용 order: flowControls.length + 1,
selectedRelationshipId: null, // 노드 플로우는 관계 ID 불필요 };
flowConfig: {
flowId: selectedFlow.flowId, const updatedControls = [...flowControls, newControl];
flowName: selectedFlow.flowName, updateFlowControls(updatedControls);
executionTiming: "before", // 기본값 }, [flowControls]);
contextData: {},
}, /**
}); * 🔥
} */
const handleRemoveControl = useCallback(
(controlId: string) => {
const updatedControls = flowControls
.filter((c) => c.id !== controlId)
.map((c, index) => ({ ...c, order: index + 1 }));
updateFlowControls(updatedControls);
},
[flowControls],
);
/**
* 🔥
*/
const handleFlowSelect = useCallback(
(controlId: string, flowId: string) => {
const selectedFlow = flows.find((f) => f.flowId.toString() === flowId);
if (selectedFlow) {
const updatedControls = flowControls.map((c) =>
c.id === controlId ? { ...c, flowId: selectedFlow.flowId, flowName: selectedFlow.flowName } : c,
);
updateFlowControls(updatedControls);
}
},
[flows, flowControls],
);
/**
* 🔥
*/
const handleTimingChange = useCallback(
(controlId: string, timing: "before" | "after" | "replace") => {
const updatedControls = flowControls.map((c) => (c.id === controlId ? { ...c, executionTiming: timing } : c));
updateFlowControls(updatedControls);
},
[flowControls],
);
/**
* 🔥
*/
const handleMoveUp = useCallback(
(controlId: string) => {
const index = flowControls.findIndex((c) => c.id === controlId);
if (index > 0) {
const updatedControls = [...flowControls];
[updatedControls[index - 1], updatedControls[index]] = [updatedControls[index], updatedControls[index - 1]];
// 순서 번호 재정렬
updatedControls.forEach((c, i) => (c.order = i + 1));
updateFlowControls(updatedControls);
}
},
[flowControls],
);
/**
* 🔥
*/
const handleMoveDown = useCallback(
(controlId: string) => {
const index = flowControls.findIndex((c) => c.id === controlId);
if (index < flowControls.length - 1) {
const updatedControls = [...flowControls];
[updatedControls[index], updatedControls[index + 1]] = [updatedControls[index + 1], updatedControls[index]];
// 순서 번호 재정렬
updatedControls.forEach((c, i) => (c.order = i + 1));
updateFlowControls(updatedControls);
}
},
[flowControls],
);
/**
* 🔥 ( )
*/
const updateFlowControls = (controls: FlowControlConfig[]) => {
// 첫 번째 제어를 기존 형식으로도 저장 (하위 호환성)
const firstValidControl = controls.find((c) => c.flowId > 0);
onUpdateProperty("webTypeConfig.dataflowConfig", {
...dataflowConfig,
// 기존 형식 (하위 호환성)
selectedDiagramId: firstValidControl?.flowId || null,
selectedRelationshipId: null,
flowConfig: firstValidControl
? {
flowId: firstValidControl.flowId,
flowName: firstValidControl.flowName,
executionTiming: firstValidControl.executionTiming,
contextData: {},
}
: null,
// 새로운 다중 제어 형식
flowControls: controls,
});
}; };
return ( return (
@ -98,32 +209,57 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
{/* 🔥 제어관리가 활성화된 경우에만 설정 표시 */} {/* 🔥 제어관리가 활성화된 경우에만 설정 표시 */}
{config.enableDataflowControl && ( {config.enableDataflowControl && (
<div className="space-y-4"> <div className="space-y-4">
<FlowSelector {/* 제어 목록 헤더 */}
flows={flows} <div className="flex items-center justify-between">
selectedFlowId={dataflowConfig.flowConfig?.flowId} <div className="flex items-center space-x-2">
onSelect={handleFlowSelect} <Workflow className="h-4 w-4 text-green-600" />
loading={loading} <Label> ( )</Label>
/> </div>
<Button variant="outline" size="sm" onClick={handleAddControl} className="h-8">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{dataflowConfig.flowConfig && ( {/* 제어 목록 */}
<div className="space-y-4"> {flowControls.length === 0 ? (
<Separator /> <div className="rounded-md border border-dashed p-6 text-center">
<ExecutionTimingSelector <Workflow className="mx-auto h-8 w-8 text-gray-400" />
value={dataflowConfig.flowConfig.executionTiming} <p className="mt-2 text-sm text-gray-500"> </p>
onChange={(timing) => <Button variant="outline" size="sm" onClick={handleAddControl} className="mt-3">
onUpdateProperty("webTypeConfig.dataflowConfig.flowConfig.executionTiming", timing) <Plus className="mr-1 h-3 w-3" />
} </Button>
/> </div>
) : (
<div className="space-y-2">
{flowControls.map((control, index) => (
<FlowControlItem
key={control.id}
control={control}
flows={flows}
loading={loading}
isFirst={index === 0}
isLast={index === flowControls.length - 1}
onFlowSelect={(flowId) => handleFlowSelect(control.id, flowId)}
onTimingChange={(timing) => handleTimingChange(control.id, timing)}
onMoveUp={() => handleMoveUp(control.id)}
onMoveDown={() => handleMoveDown(control.id)}
onRemove={() => handleRemoveControl(control.id)}
/>
))}
</div>
)}
<div className="rounded bg-green-50 p-3"> {/* 안내 메시지 */}
<div className="flex items-start space-x-2"> {flowControls.length > 0 && (
<Info className="mt-0.5 h-4 w-4 text-green-600" /> <div className="rounded bg-blue-50 p-3">
<div className="text-xs text-green-800"> <div className="flex items-start space-x-2">
<p className="font-medium"> :</p> <Info className="mt-0.5 h-4 w-4 text-blue-600" />
<p className="mt-1"> / .</p> <div className="text-xs text-blue-800">
<p className="mt-1"> 트랜잭션: /</p> <p className="font-medium"> :</p>
<p> 중단: 부모 </p> <p className="mt-1"> </p>
</div> <p> </p>
<p> </p>
</div> </div>
</div> </div>
</div> </div>
@ -135,90 +271,89 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
}; };
/** /**
* 🔥 * 🔥
*/ */
const FlowSelector: React.FC<{ const FlowControlItem: React.FC<{
control: FlowControlConfig;
flows: NodeFlow[]; flows: NodeFlow[];
selectedFlowId?: number;
onSelect: (flowId: string) => void;
loading: boolean; loading: boolean;
}> = ({ flows, selectedFlowId, onSelect, loading }) => { isFirst: boolean;
isLast: boolean;
onFlowSelect: (flowId: string) => void;
onTimingChange: (timing: "before" | "after" | "replace") => void;
onMoveUp: () => void;
onMoveDown: () => void;
onRemove: () => void;
}> = ({ control, flows, loading, isFirst, isLast, onFlowSelect, onTimingChange, onMoveUp, onMoveDown, onRemove }) => {
return ( return (
<div className="space-y-4"> <Card className="p-3">
<div className="flex items-center space-x-2"> <div className="flex items-start gap-2">
<Workflow className="h-4 w-4 text-green-600" /> {/* 순서 표시 및 이동 버튼 */}
<Label> </Label> <div className="flex flex-col items-center gap-1">
</div> <Badge variant="secondary" className="h-6 w-6 justify-center rounded-full p-0 text-xs">
{control.order}
</Badge>
<div className="flex flex-col">
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onMoveUp} disabled={isFirst}>
<ChevronUp className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onMoveDown} disabled={isLast}>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
</div>
<Select value={selectedFlowId?.toString() || ""} onValueChange={onSelect}> {/* 플로우 선택 및 설정 */}
<SelectTrigger> <div className="flex-1 space-y-2">
<SelectValue placeholder="플로우를 선택하세요" /> {/* 플로우 선택 */}
</SelectTrigger> <Select value={control.flowId > 0 ? control.flowId.toString() : ""} onValueChange={onFlowSelect}>
<SelectContent> <SelectTrigger className="h-8 text-xs">
{loading ? ( <SelectValue placeholder="플로우를 선택하세요" />
<div className="p-4 text-center text-sm text-gray-500"> ...</div> </SelectTrigger>
) : flows.length === 0 ? ( <SelectContent>
<div className="p-4 text-center text-sm text-gray-500"> {loading ? (
<p> </p> <div className="p-2 text-center text-xs text-gray-500"> ...</div>
<p className="mt-2 text-xs"> </p> ) : flows.length === 0 ? (
</div> <div className="p-2 text-center text-xs text-gray-500"> </div>
) : ( ) : (
flows.map((flow) => ( flows.map((flow) => (
<SelectItem key={flow.flowId} value={flow.flowId.toString()}> <SelectItem key={flow.flowId} value={flow.flowId.toString()}>
<div className="flex flex-col"> <span className="text-xs">{flow.flowName}</span>
<span className="font-medium">{flow.flowName}</span> </SelectItem>
{flow.flowDescription && ( ))
<span className="text-muted-foreground text-xs">{flow.flowDescription}</span> )}
)} </SelectContent>
</div> </Select>
{/* 실행 타이밍 */}
<Select value={control.executionTiming} onValueChange={onTimingChange}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="before">
<span className="text-xs">Before ( )</span>
</SelectItem> </SelectItem>
)) <SelectItem value="after">
)} <span className="text-xs">After ( )</span>
</SelectContent> </SelectItem>
</Select> <SelectItem value="replace">
</div> <span className="text-xs">Replace ( )</span>
); </SelectItem>
}; </SelectContent>
</Select>
</div>
/** {/* 삭제 버튼 */}
* 🔥 <Button
*/ variant="ghost"
const ExecutionTimingSelector: React.FC<{ size="icon"
value: string; className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-600"
onChange: (timing: "before" | "after" | "replace") => void; onClick={onRemove}
}> = ({ value, onChange }) => { >
return ( <Trash2 className="h-4 w-4" />
<div className="space-y-2"> </Button>
<div className="flex items-center space-x-2">
<Clock className="h-4 w-4 text-orange-600" />
<Label> </Label>
</div> </div>
</Card>
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="실행 타이밍을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="before">
<div className="flex flex-col">
<span className="font-medium">Before ( )</span>
<span className="text-muted-foreground text-xs"> </span>
</div>
</SelectItem>
<SelectItem value="after">
<div className="flex flex-col">
<span className="font-medium">After ( )</span>
<span className="text-muted-foreground text-xs"> </span>
</div>
</SelectItem>
<SelectItem value="replace">
<div className="flex flex-col">
<span className="font-medium">Replace ( )</span>
<span className="text-muted-foreground text-xs"> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
); );
}; };

View File

@ -192,3 +192,4 @@ export function applyAutoFillToFormData(
return result; return result;
} }

View File

@ -576,7 +576,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const firstEntry = allProviders.entries().next().value; const firstEntry = allProviders.entries().next().value;
if (firstEntry) { if (firstEntry) {
sourceProvider = firstEntry[1]; sourceProvider = firstEntry[1];
console.log(`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`); console.log(
`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`,
);
} }
} }
@ -589,7 +591,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const rawSourceData = sourceProvider.getSelectedData(); const rawSourceData = sourceProvider.getSelectedData();
// 🆕 배열이 아닌 경우 배열로 변환 // 🆕 배열이 아닌 경우 배열로 변환
const sourceData = Array.isArray(rawSourceData) ? rawSourceData : (rawSourceData ? [rawSourceData] : []); const sourceData = Array.isArray(rawSourceData) ? rawSourceData : rawSourceData ? [rawSourceData] : [];
console.log("📦 소스 데이터:", { rawSourceData, sourceData, isArray: Array.isArray(rawSourceData) }); console.log("📦 소스 데이터:", { rawSourceData, sourceData, isArray: Array.isArray(rawSourceData) });
@ -615,7 +617,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// fieldName이 지정되어 있으면 그 필드만 추출 // fieldName이 지정되어 있으면 그 필드만 추출
if (additionalSource.fieldName) { if (additionalSource.fieldName) {
additionalData[additionalSource.fieldName] = firstValue[additionalSource.fieldName] || firstValue.condition || firstValue; additionalData[additionalSource.fieldName] =
firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
} else { } else {
// fieldName이 없으면 전체 객체 병합 // fieldName이 없으면 전체 객체 병합
additionalData = { ...additionalData, ...firstValue }; additionalData = { ...additionalData, ...firstValue };
@ -624,7 +627,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
console.log("📦 추가 데이터 수집 (additionalSources):", { console.log("📦 추가 데이터 수집 (additionalSources):", {
sourceId: additionalSource.componentId, sourceId: additionalSource.componentId,
fieldName: additionalSource.fieldName, fieldName: additionalSource.fieldName,
value: additionalData[additionalSource.fieldName || 'all'], value: additionalData[additionalSource.fieldName || "all"],
}); });
} }
} }
@ -651,7 +654,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} else { } else {
// controlField가 없으면 기존 방식: formData에서 같은 값을 가진 키 찾기 // controlField가 없으면 기존 방식: formData에서 같은 값을 가진 키 찾기
for (const [key, value] of Object.entries(formData)) { for (const [key, value] of Object.entries(formData)) {
if (value === conditionalValue && !key.startsWith('__')) { if (value === conditionalValue && !key.startsWith("__")) {
additionalData[key] = conditionalValue; additionalData[key] = conditionalValue;
console.log("📦 조건부 컨테이너 값 자동 포함:", { console.log("📦 조건부 컨테이너 값 자동 포함:", {
fieldName: key, fieldName: key,
@ -663,10 +666,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
// 못 찾았으면 기본 필드명 사용 // 못 찾았으면 기본 필드명 사용
if (!Object.keys(additionalData).some(k => !k.startsWith('__'))) { if (!Object.keys(additionalData).some((k) => !k.startsWith("__"))) {
additionalData['condition_type'] = conditionalValue; additionalData["condition_type"] = conditionalValue;
console.log("📦 조건부 컨테이너 값 (기본 필드명):", { console.log("📦 조건부 컨테이너 값 (기본 필드명):", {
fieldName: 'condition_type', fieldName: "condition_type",
value: conditionalValue, value: conditionalValue,
}); });
} }
@ -742,7 +745,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 🆕 useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동) // 🆕 useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동)
// screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로, // screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로,
// SplitPanelPositionProvider로 전달된 위치를 우선 사용 // SplitPanelPositionProvider로 전달된 위치를 우선 사용
const currentPosition = splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null); const currentPosition =
splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null);
if (!currentPosition) { if (!currentPosition) {
toast.error("분할 패널 내 위치를 확인할 수 없습니다. screenId: " + screenId); toast.error("분할 패널 내 위치를 확인할 수 없습니다. screenId: " + screenId);
@ -761,7 +765,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
currentPosition, currentPosition,
mappedData, mappedData,
dataTransferConfig.targetComponentId, // 특정 컴포넌트 지정 (선택사항) dataTransferConfig.targetComponentId, // 특정 컴포넌트 지정 (선택사항)
dataTransferConfig.mode || "append" dataTransferConfig.mode || "append",
); );
if (result.success) { if (result.success) {
@ -782,7 +786,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
if (dataTransferConfig.clearAfterTransfer) { if (dataTransferConfig.clearAfterTransfer) {
sourceProvider.clearSelection(); sourceProvider.clearSelection();
} }
} catch (error: any) { } catch (error: any) {
console.error("❌ 데이터 전달 실패:", error); console.error("❌ 데이터 전달 실패:", error);
toast.error(error.message || "데이터 전달 중 오류가 발생했습니다."); toast.error(error.message || "데이터 전달 중 오류가 발생했습니다.");
@ -818,7 +821,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
let effectiveSelectedRowsData = selectedRowsData; let effectiveSelectedRowsData = selectedRowsData;
// groupedData가 있으면 우선 사용 (모달에서 부모 데이터 접근) // groupedData가 있으면 우선 사용 (모달에서 부모 데이터 접근)
if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && groupedData && groupedData.length > 0) { if (
(!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) &&
groupedData &&
groupedData.length > 0
) {
effectiveSelectedRowsData = groupedData; effectiveSelectedRowsData = groupedData;
console.log("🔗 [ButtonPrimaryComponent] groupedData에서 부모창 데이터 가져옴:", { console.log("🔗 [ButtonPrimaryComponent] groupedData에서 부모창 데이터 가져옴:", {
count: groupedData.length, count: groupedData.length,
@ -833,11 +840,17 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const dataRegistry = useModalDataStore.getState().dataRegistry; const dataRegistry = useModalDataStore.getState().dataRegistry;
const modalData = dataRegistry[effectiveTableName]; const modalData = dataRegistry[effectiveTableName];
if (modalData && modalData.length > 0) { if (modalData && modalData.length > 0) {
effectiveSelectedRowsData = modalData; // modalDataStore는 {id, originalData, additionalData} 형태로 저장됨
// originalData를 추출하여 실제 행 데이터를 가져옴
effectiveSelectedRowsData = modalData.map((item: any) => {
// originalData가 있으면 그것을 사용, 없으면 item 자체 사용 (하위 호환성)
return item.originalData || item;
});
console.log("🔗 [ButtonPrimaryComponent] modalDataStore에서 선택된 데이터 가져옴:", { console.log("🔗 [ButtonPrimaryComponent] modalDataStore에서 선택된 데이터 가져옴:", {
tableName: effectiveTableName, tableName: effectiveTableName,
count: modalData.length, count: modalData.length,
data: modalData, rawData: modalData,
extractedData: effectiveSelectedRowsData,
}); });
} }
} catch (error) { } catch (error) {
@ -847,7 +860,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단 // 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단
const hasDataToDelete = const hasDataToDelete =
(effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0); (effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) ||
(flowSelectedData && flowSelectedData.length > 0);
if (processedConfig.action.type === "delete" && !hasDataToDelete) { if (processedConfig.action.type === "delete" && !hasDataToDelete) {
toast.warning("삭제할 항목을 먼저 선택해주세요."); toast.warning("삭제할 항목을 먼저 선택해주세요.");
@ -1064,15 +1078,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
// 🔧 크기에 따른 패딩 조정 // 🔧 크기에 따른 패딩 조정
padding: padding: componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
margin: "0", margin: "0",
lineHeight: "1.25", lineHeight: "1.25",
boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)", boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외) // 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외)
...(component.style ? Object.fromEntries( ...(component.style
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height') ? Object.fromEntries(Object.entries(component.style).filter(([key]) => key !== "width" && key !== "height"))
) : {}), : {}),
}; };
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼"; const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";
@ -1094,7 +1107,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
<button <button
type={componentConfig.actionType || "button"} type={componentConfig.actionType || "button"}
disabled={finalDisabled} disabled={finalDisabled}
className="transition-colors duration-150 hover:opacity-90 active:scale-95 transition-transform" className="transition-colors transition-transform duration-150 hover:opacity-90 active:scale-95"
style={buttonElementStyle} style={buttonElementStyle}
onClick={handleClick} onClick={handleClick}
onDragStart={onDragStart} onDragStart={onDragStart}

View File

@ -715,8 +715,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const colName = typeof col === "string" ? col : col.name || col.columnName; const colName = typeof col === "string" ? col : col.name || col.columnName;
if (colName && colName.includes(".")) { if (colName && colName.includes(".")) {
const [refTable, refColumn] = colName.split("."); const [refTable, refColumn] = colName.split(".");
// 소스 컬럼 추론 (item_info → item_code) // 소스 컬럼 추론 (item_info → item_code 또는 warehouse_info → warehouse_id)
const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); // 기본: _info → _code, 백업: _info → _id
const primarySourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
const secondarySourceColumn = refTable.replace("_info", "_id").replace("_mng", "_id");
// 실제 존재하는 소스 컬럼은 백엔드에서 결정 (프론트엔드는 두 패턴 모두 전달)
const inferredSourceColumn = primarySourceColumn;
// 이미 추가된 조인인지 확인 (동일 테이블, 동일 소스컬럼) // 이미 추가된 조인인지 확인 (동일 테이블, 동일 소스컬럼)
const existingJoin = additionalJoinColumns.find( const existingJoin = additionalJoinColumns.find(

View File

@ -200,29 +200,49 @@ export function UniversalFormModalComponent({
// 초기 데이터를 한 번만 캡처 (컴포넌트 마운트 시) // 초기 데이터를 한 번만 캡처 (컴포넌트 마운트 시)
const capturedInitialData = useRef<Record<string, any> | undefined>(undefined); const capturedInitialData = useRef<Record<string, any> | undefined>(undefined);
const hasInitialized = useRef(false); const hasInitialized = useRef(false);
// 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요)
const lastInitializedId = useRef<string | undefined>(undefined);
// 초기화 - 최초 마운트 시에만 실행 // 초기화 - 최초 마운트 시 또는 initialData의 ID가 변경되었을 때 실행
useEffect(() => { useEffect(() => {
// 이미 초기화되었으면 스킵 // initialData에서 ID 값 추출 (id, ID, objid 등)
if (hasInitialized.current) { const currentId = initialData?.id || initialData?.ID || initialData?.objid;
const currentIdString = currentId !== undefined ? String(currentId) : undefined;
// 이미 초기화되었고, ID가 동일하면 스킵
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
return; return;
} }
// 🆕 수정 모드: initialData에 데이터가 있으면서 ID가 변경된 경우 재초기화
if (hasInitialized.current && currentIdString && lastInitializedId.current !== currentIdString) {
console.log("[UniversalFormModal] ID 변경 감지 - 재초기화:", {
prevId: lastInitializedId.current,
newId: currentIdString,
initialData: initialData,
});
// 채번 플래그 초기화 (새 항목이므로)
numberingGeneratedRef.current = false;
isGeneratingRef.current = false;
}
// 최초 initialData 캡처 (이후 변경되어도 이 값 사용) // 최초 initialData 캡처 (이후 변경되어도 이 값 사용)
if (initialData && Object.keys(initialData).length > 0) { if (initialData && Object.keys(initialData).length > 0) {
capturedInitialData.current = JSON.parse(JSON.stringify(initialData)); // 깊은 복사 capturedInitialData.current = JSON.parse(JSON.stringify(initialData)); // 깊은 복사
lastInitializedId.current = currentIdString;
console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current);
} }
hasInitialized.current = true; hasInitialized.current = true;
initializeForm(); initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 빈 의존성 배열 - 마운트 시 한 번만 실행 }, [initialData?.id, initialData?.ID, initialData?.objid]); // ID 값 변경 시 재초기화
// config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외 // config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외
useEffect(() => { useEffect(() => {
if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵 if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵
console.log('[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)'); console.log("[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)");
// initializeForm(); // 주석 처리 - config 변경 시 재초기화 안 함 (채번 중복 방지) // initializeForm(); // 주석 처리 - config 변경 시 재초기화 안 함 (채번 중복 방지)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]); }, [config]);
@ -230,7 +250,7 @@ export function UniversalFormModalComponent({
// 컴포넌트 unmount 시 채번 플래그 초기화 // 컴포넌트 unmount 시 채번 플래그 초기화
useEffect(() => { useEffect(() => {
return () => { return () => {
console.log('[채번] 컴포넌트 unmount - 플래그 초기화'); console.log("[채번] 컴포넌트 unmount - 플래그 초기화");
numberingGeneratedRef.current = false; numberingGeneratedRef.current = false;
isGeneratingRef.current = false; isGeneratingRef.current = false;
}; };
@ -316,11 +336,18 @@ export function UniversalFormModalComponent({
// 폼 초기화 // 폼 초기화
const initializeForm = useCallback(async () => { const initializeForm = useCallback(async () => {
console.log('[initializeForm] 시작'); console.log("[initializeForm] 시작");
// 캡처된 initialData 사용 (props로 전달된 initialData가 아닌) // 캡처된 initialData 사용 (props로 전달된 initialData가 아닌)
const effectiveInitialData = capturedInitialData.current || initialData; const effectiveInitialData = capturedInitialData.current || initialData;
console.log("[initializeForm] 초기 데이터:", {
capturedInitialData: capturedInitialData.current,
initialData: initialData,
effectiveInitialData: effectiveInitialData,
hasData: effectiveInitialData && Object.keys(effectiveInitialData).length > 0,
});
const newFormData: FormDataState = {}; const newFormData: FormDataState = {};
const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {}; const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {};
const newCollapsed = new Set<string>(); const newCollapsed = new Set<string>();
@ -368,9 +395,9 @@ export function UniversalFormModalComponent({
setOriginalData(effectiveInitialData || {}); setOriginalData(effectiveInitialData || {});
// 채번규칙 자동 생성 // 채번규칙 자동 생성
console.log('[initializeForm] generateNumberingValues 호출'); console.log("[initializeForm] generateNumberingValues 호출");
await generateNumberingValues(newFormData); await generateNumberingValues(newFormData);
console.log('[initializeForm] 완료'); console.log("[initializeForm] 완료");
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용) }, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
@ -396,17 +423,17 @@ export function UniversalFormModalComponent({
async (currentFormData: FormDataState) => { async (currentFormData: FormDataState) => {
// 이미 생성되었거나 진행 중이면 스킵 // 이미 생성되었거나 진행 중이면 스킵
if (numberingGeneratedRef.current) { if (numberingGeneratedRef.current) {
console.log('[채번] 이미 생성됨 - 스킵'); console.log("[채번] 이미 생성됨 - 스킵");
return; return;
} }
if (isGeneratingRef.current) { if (isGeneratingRef.current) {
console.log('[채번] 생성 진행 중 - 스킵'); console.log("[채번] 생성 진행 중 - 스킵");
return; return;
} }
isGeneratingRef.current = true; // 진행 중 표시 isGeneratingRef.current = true; // 진행 중 표시
console.log('[채번] 미리보기 생성 시작'); console.log("[채번] 생성 시작");
const updatedData = { ...currentFormData }; const updatedData = { ...currentFormData };
let hasChanges = false; let hasChanges = false;
@ -434,14 +461,16 @@ export function UniversalFormModalComponent({
hasChanges = true; hasChanges = true;
numberingGeneratedRef.current = true; // 생성 완료 표시 numberingGeneratedRef.current = true; // 생성 완료 표시
console.log(`[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`); console.log(
`[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`,
);
console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`); console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`);
// 부모 컴포넌트에도 ruleId 전달 (ModalRepeaterTable → ScreenModal) // 부모 컴포넌트에도 ruleId 전달 (ModalRepeaterTable → ScreenModal)
if (onChange) { if (onChange) {
onChange({ onChange({
...updatedData, ...updatedData,
[ruleIdKey]: field.numberingRule.ruleId [ruleIdKey]: field.numberingRule.ruleId,
}); });
console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`); console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`);
} }

View File

@ -2670,6 +2670,7 @@ export class ButtonActionExecutor {
/** /**
* (After Timing) * (After Timing)
*
*/ */
private static async executeAfterSaveControl( private static async executeAfterSaveControl(
config: ButtonActionConfig, config: ButtonActionConfig,
@ -2681,12 +2682,6 @@ export class ButtonActionExecutor {
dataflowTiming: config.dataflowTiming, dataflowTiming: config.dataflowTiming,
}); });
// dataflowTiming이 'after'가 아니면 실행하지 않음
if (config.dataflowTiming && config.dataflowTiming !== "after") {
console.log("⏭️ dataflowTiming이 'after'가 아니므로 제어 실행 건너뜀:", config.dataflowTiming);
return;
}
// 제어 데이터 소스 결정 // 제어 데이터 소스 결정
let controlDataSource = config.dataflowConfig?.controlDataSource; let controlDataSource = config.dataflowConfig?.controlDataSource;
if (!controlDataSource) { if (!controlDataSource) {
@ -2700,9 +2695,117 @@ export class ButtonActionExecutor {
controlDataSource, controlDataSource,
}; };
// 🔥 다중 제어 지원 (flowControls 배열)
const flowControls = config.dataflowConfig?.flowControls || [];
if (flowControls.length > 0) {
console.log(`🎯 다중 제어 순차 실행 시작: ${flowControls.length}`);
// 순서대로 정렬
const sortedControls = [...flowControls].sort((a: any, b: any) => (a.order || 0) - (b.order || 0));
// 노드 플로우 실행 API
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
// 데이터 소스 준비
const sourceData: any = context.formData || {};
let allSuccess = true;
const results: Array<{ flowId: number; flowName: string; success: boolean; message?: string }> = [];
for (let i = 0; i < sortedControls.length; i++) {
const control = sortedControls[i];
// 유효하지 않은 flowId 스킵
if (!control.flowId || control.flowId <= 0) {
console.warn(`⚠️ [${i + 1}/${sortedControls.length}] 유효하지 않은 flowId, 스킵:`, control);
continue;
}
// executionTiming 체크 (after만 실행)
if (control.executionTiming && control.executionTiming !== "after") {
console.log(
`⏭️ [${i + 1}/${sortedControls.length}] executionTiming이 'after'가 아님, 스킵:`,
control.executionTiming,
);
continue;
}
console.log(
`\n📍 [${i + 1}/${sortedControls.length}] 제어 실행: ${control.flowName} (flowId: ${control.flowId})`,
);
try {
const result = await executeNodeFlow(control.flowId, {
dataSourceType: controlDataSource,
sourceData,
context: extendedContext,
});
results.push({
flowId: control.flowId,
flowName: control.flowName,
success: result.success,
message: result.message,
});
if (result.success) {
console.log(`✅ [${i + 1}/${sortedControls.length}] 제어 성공: ${control.flowName}`);
} else {
console.error(`❌ [${i + 1}/${sortedControls.length}] 제어 실패: ${control.flowName} - ${result.message}`);
allSuccess = false;
// 이전 제어 실패 시 다음 제어 실행 중단
console.warn("⚠️ 이전 제어 실패로 인해 나머지 제어 실행 중단");
break;
}
} catch (error: any) {
console.error(`❌ [${i + 1}/${sortedControls.length}] 제어 실행 오류: ${control.flowName}`, error);
results.push({
flowId: control.flowId,
flowName: control.flowName,
success: false,
message: error.message,
});
allSuccess = false;
break;
}
}
// 결과 요약
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
console.log("\n📊 다중 제어 실행 완료:", {
total: sortedControls.length,
executed: results.length,
success: successCount,
failed: failCount,
});
if (allSuccess) {
toast.success(`${successCount}개 제어 실행 완료`);
} else {
toast.error(`제어 실행 중 오류 발생 (${successCount}/${results.length} 성공)`);
}
return;
}
// 🔥 기존 단일 제어 실행 (하위 호환성)
// dataflowTiming이 'after'가 아니면 실행하지 않음
if (config.dataflowTiming && config.dataflowTiming !== "after") {
console.log("⏭️ dataflowTiming이 'after'가 아니므로 제어 실행 건너뜀:", config.dataflowTiming);
return;
}
// 노드 플로우 방식 실행 (flowConfig가 있는 경우) // 노드 플로우 방식 실행 (flowConfig가 있는 경우)
const hasFlowConfig = config.dataflowConfig?.flowConfig && config.dataflowConfig.flowConfig.flowId; const hasFlowConfig = config.dataflowConfig?.flowConfig && config.dataflowConfig.flowConfig.flowId;
if (hasFlowConfig) { if (hasFlowConfig) {
// executionTiming 체크
const flowTiming = config.dataflowConfig.flowConfig.executionTiming;
if (flowTiming && flowTiming !== "after") {
console.log("⏭️ flowConfig.executionTiming이 'after'가 아니므로 제어 실행 건너뜀:", flowTiming);
return;
}
console.log("🎯 저장 후 노드 플로우 실행:", config.dataflowConfig.flowConfig); console.log("🎯 저장 후 노드 플로우 실행:", config.dataflowConfig.flowConfig);
const { flowId } = config.dataflowConfig.flowConfig; const { flowId } = config.dataflowConfig.flowConfig;

View File

@ -1684,3 +1684,4 @@ const 출고등록_설정: ScreenSplitPanel = {

View File

@ -531,3 +531,4 @@ const { data: config } = await getScreenSplitPanel(screenId);

View File

@ -518,3 +518,4 @@ function ScreenViewPage() {