Merge pull request 'feature/screen-management' (#285) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/285
This commit is contained in:
commit
665d1b51d8
|
|
@ -50,3 +50,4 @@ router.get("/data/:groupCode", getAutoFillData);
|
|||
|
||||
export default router;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -46,3 +46,4 @@ router.get("/filtered-options/:relationCode", getFilteredOptions);
|
|||
|
||||
export default router;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -62,3 +62,4 @@ router.get("/:groupCode/options/:levelOrder", getLevelOptions);
|
|||
|
||||
export default router;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -50,3 +50,4 @@ router.get("/options/:exclusionCode", getExcludedOptions);
|
|||
|
||||
export default router;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -903,7 +903,7 @@ export class DynamicFormService {
|
|||
return `${key} = $${index + 1}::numeric`;
|
||||
} else if (dataType === "boolean") {
|
||||
return `${key} = $${index + 1}::boolean`;
|
||||
} else if (dataType === 'jsonb' || dataType === 'json') {
|
||||
} else if (dataType === "jsonb" || dataType === "json") {
|
||||
// 🆕 JSONB/JSON 타입은 명시적 캐스팅
|
||||
return `${key} = $${index + 1}::jsonb`;
|
||||
} else {
|
||||
|
|
@ -917,9 +917,13 @@ export class DynamicFormService {
|
|||
const values: any[] = Object.keys(changedFields).map((key) => {
|
||||
const value = changedFields[key];
|
||||
const dataType = columnTypes[key];
|
||||
|
||||
|
||||
// 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 value;
|
||||
|
|
@ -1588,6 +1592,7 @@ export class DynamicFormService {
|
|||
|
||||
/**
|
||||
* 제어관리 실행 (화면에 설정된 경우)
|
||||
* 다중 제어를 순서대로 순차 실행 지원
|
||||
*/
|
||||
private async executeDataflowControlIfConfigured(
|
||||
screenId: number,
|
||||
|
|
@ -1629,105 +1634,67 @@ export class DynamicFormService {
|
|||
hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig,
|
||||
hasDiagramId:
|
||||
!!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId,
|
||||
hasFlowControls:
|
||||
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
|
||||
});
|
||||
|
||||
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
|
||||
if (
|
||||
properties?.componentType === "button-primary" &&
|
||||
properties?.componentConfig?.action?.type === "save" &&
|
||||
properties?.webTypeConfig?.enableDataflowControl === true &&
|
||||
properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId
|
||||
properties?.webTypeConfig?.enableDataflowControl === true
|
||||
) {
|
||||
controlConfigFound = true;
|
||||
const diagramId =
|
||||
properties.webTypeConfig.dataflowConfig.selectedDiagramId;
|
||||
const relationshipId =
|
||||
properties.webTypeConfig.dataflowConfig.selectedRelationshipId;
|
||||
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
|
||||
|
||||
console.log(`🎯 제어관리 설정 발견:`, {
|
||||
componentId: layout.component_id,
|
||||
diagramId,
|
||||
relationshipId,
|
||||
triggerType,
|
||||
});
|
||||
// 다중 제어 설정 확인 (flowControls 배열)
|
||||
const flowControls = dataflowConfig?.flowControls || [];
|
||||
|
||||
// 노드 플로우 실행 (relationshipId가 없는 경우 노드 플로우로 간주)
|
||||
let controlResult: any;
|
||||
// flowControls가 있으면 다중 제어 실행, 없으면 기존 단일 제어 실행
|
||||
if (flowControls.length > 0) {
|
||||
controlConfigFound = true;
|
||||
console.log(`🎯 다중 제어관리 설정 발견: ${flowControls.length}개`);
|
||||
|
||||
if (!relationshipId) {
|
||||
// 노드 플로우 실행
|
||||
console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`);
|
||||
const { NodeFlowExecutionService } = await import(
|
||||
"./nodeFlowExecutionService"
|
||||
// 순서대로 정렬
|
||||
const sortedControls = [...flowControls].sort(
|
||||
(a: any, b: any) => (a.order || 0) - (b.order || 0)
|
||||
);
|
||||
|
||||
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,
|
||||
{
|
||||
sourceData: [savedData],
|
||||
dataSourceType: "formData",
|
||||
buttonId: "save-button",
|
||||
screenId: screenId,
|
||||
userId: userId,
|
||||
companyCode: companyCode,
|
||||
formData: savedData,
|
||||
}
|
||||
);
|
||||
relationshipId,
|
||||
triggerType,
|
||||
});
|
||||
|
||||
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})`
|
||||
await this.executeSingleFlowControl(
|
||||
diagramId,
|
||||
relationshipId,
|
||||
savedData,
|
||||
screenId,
|
||||
tableName,
|
||||
triggerType,
|
||||
userId,
|
||||
companyCode
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 특정 필드 값만 업데이트
|
||||
* (다른 테이블의 레코드 업데이트 지원)
|
||||
|
|
|
|||
|
|
@ -582,3 +582,4 @@ const result = await executeNodeFlow(flowId, {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -355,3 +355,4 @@
|
|||
- [ ] 부모 화면에서 모달로 데이터가 전달되는가?
|
||||
- [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가?
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// 폼 데이터 상태 (편집 데이터로 초기화됨)
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [originalData, setOriginalData] = useState<Record<string, any>>({});
|
||||
|
||||
|
||||
// 🆕 그룹 데이터 상태 (같은 order_no의 모든 품목)
|
||||
const [groupData, setGroupData] = useState<Record<string, any>[]>([]);
|
||||
const [originalGroupData, setOriginalGroupData] = useState<Record<string, any>[]>([]);
|
||||
|
|
@ -118,7 +118,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// 전역 모달 이벤트 리스너
|
||||
useEffect(() => {
|
||||
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({
|
||||
isOpen: true,
|
||||
|
|
@ -136,8 +137,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
setFormData(editData || {});
|
||||
// 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드)
|
||||
// originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨
|
||||
setOriginalData(isCreateMode ? {} : (editData || {}));
|
||||
|
||||
setOriginalData(isCreateMode ? {} : editData || {});
|
||||
|
||||
if (isCreateMode) {
|
||||
console.log("[EditModal] 생성 모드로 열림, 초기값:", editData);
|
||||
}
|
||||
|
|
@ -170,7 +171,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
useEffect(() => {
|
||||
if (modalState.isOpen && modalState.screenId) {
|
||||
loadScreenData(modalState.screenId);
|
||||
|
||||
|
||||
// 🆕 그룹 데이터 조회 (groupByColumns가 있는 경우)
|
||||
if (modalState.groupByColumns && modalState.groupByColumns.length > 0 && modalState.tableName) {
|
||||
loadGroupData();
|
||||
|
|
@ -308,7 +309,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// universal-form-modal 등에서 자체 저장 완료 후 호출된 경우 스킵
|
||||
if (saveData?._saveCompleted) {
|
||||
console.log("[EditModal] 자체 저장 완료된 컴포넌트에서 호출됨 - 저장 스킵");
|
||||
|
||||
|
||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||
if (modalState.onSave) {
|
||||
try {
|
||||
|
|
@ -317,7 +318,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
console.error("onSave 콜백 에러:", callbackError);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
|
|
@ -342,13 +343,13 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// 🆕 날짜 필드 정규화 함수 (YYYY-MM-DD 형식으로 변환)
|
||||
const normalizeDateField = (value: any): string | null => {
|
||||
if (!value) return null;
|
||||
|
||||
|
||||
// ISO 8601 형식 (2025-11-26T00:00:00.000Z) 또는 Date 객체
|
||||
if (value instanceof Date || typeof value === "string") {
|
||||
try {
|
||||
const date = new Date(value);
|
||||
if (isNaN(date.getTime())) return null;
|
||||
|
||||
|
||||
// YYYY-MM-DD 형식으로 변환
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
|
|
@ -359,7 +360,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
@ -380,7 +381,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
const insertData: Record<string, any> = { ...currentData };
|
||||
console.log("📦 [신규 품목] 복사 직후 insertData:", insertData);
|
||||
console.log("📋 [신규 품목] insertData 키 목록:", Object.keys(insertData));
|
||||
|
||||
|
||||
delete insertData.id; // id는 자동 생성되므로 제거
|
||||
|
||||
// 🆕 날짜 필드 정규화 (YYYY-MM-DD 형식으로 변환)
|
||||
|
|
@ -464,9 +465,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
for (const currentData of groupData) {
|
||||
if (currentData.id) {
|
||||
// id 기반 매칭 (인덱스 기반 X)
|
||||
const originalItemData = originalGroupData.find(
|
||||
(orig) => orig.id === currentData.id
|
||||
);
|
||||
const originalItemData = originalGroupData.find((orig) => orig.id === currentData.id);
|
||||
|
||||
if (!originalItemData) {
|
||||
console.warn(`원본 데이터를 찾을 수 없습니다 (id: ${currentData.id})`);
|
||||
|
|
@ -476,13 +475,13 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// 🆕 값 정규화 함수 (타입 통일)
|
||||
const normalizeValue = (val: any, fieldName?: string): any => {
|
||||
if (val === null || val === undefined || val === "") return null;
|
||||
|
||||
|
||||
// 날짜 필드인 경우 YYYY-MM-DD 형식으로 정규화
|
||||
if (fieldName && dateFields.includes(fieldName)) {
|
||||
const normalizedDate = normalizeDateField(val);
|
||||
return normalizedDate;
|
||||
}
|
||||
|
||||
|
||||
if (typeof val === "string" && !isNaN(Number(val))) {
|
||||
// 숫자로 변환 가능한 문자열은 숫자로
|
||||
return Number(val);
|
||||
|
|
@ -539,9 +538,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
|
||||
// 3️⃣ 삭제된 품목 제거 (원본에는 있지만 현재 데이터에는 없는 항목)
|
||||
const currentIds = new Set(groupData.map((item) => item.id).filter(Boolean));
|
||||
const deletedItems = originalGroupData.filter(
|
||||
(orig) => orig.id && !currentIds.has(orig.id)
|
||||
);
|
||||
const deletedItems = originalGroupData.filter((orig) => orig.id && !currentIds.has(orig.id));
|
||||
|
||||
for (const deletedItem of deletedItems) {
|
||||
console.log("🗑️ 품목 삭제:", deletedItem);
|
||||
|
|
@ -549,7 +546,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
try {
|
||||
const response = await dynamicFormApi.deleteFormDataFromTable(
|
||||
deletedItem.id,
|
||||
screenData.screenInfo.tableName
|
||||
screenData.screenInfo.tableName,
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
|
|
@ -592,11 +589,11 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
|
||||
// originalData가 비어있으면 INSERT, 있으면 UPDATE
|
||||
const isCreateMode = Object.keys(originalData).length === 0;
|
||||
|
||||
|
||||
if (isCreateMode) {
|
||||
// INSERT 모드
|
||||
console.log("[EditModal] INSERT 모드 - 새 데이터 생성:", formData);
|
||||
|
||||
|
||||
const response = await dynamicFormApi.saveFormData({
|
||||
screenId: modalState.screenId!,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
|
|
@ -701,10 +698,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
|
||||
return (
|
||||
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className={`${modalStyle.className} ${className || ""} max-w-none`}
|
||||
style={modalStyle.style}
|
||||
>
|
||||
<DialogContent className={`${modalStyle.className} ${className || ""} max-w-none`} style={modalStyle.style}>
|
||||
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<DialogTitle className="text-base">{modalState.title || "데이터 수정"}</DialogTitle>
|
||||
|
|
@ -717,7 +711,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
</div>
|
||||
</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 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -751,7 +745,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
},
|
||||
};
|
||||
|
||||
|
||||
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
||||
|
||||
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
|
||||
|
|
@ -760,7 +753,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
tableName: screenData.screenInfo?.tableName, // 테이블명 추가
|
||||
screenId: modalState.screenId, // 화면 ID 추가
|
||||
};
|
||||
|
||||
|
||||
// 🔍 디버깅: enrichedFormData 확인
|
||||
console.log("🔑 [EditModal] enrichedFormData 생성:", {
|
||||
"screenData.screenInfo": screenData.screenInfo,
|
||||
|
|
@ -775,6 +768,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
component={adjustedComponent}
|
||||
allComponents={screenData.components}
|
||||
formData={enrichedFormData}
|
||||
originalData={originalData} // 🆕 원본 데이터 전달 (수정 모드에서 UniversalFormModal 초기화용)
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
// 🆕 그룹 데이터가 있으면 처리
|
||||
if (groupData.length > 0) {
|
||||
|
|
@ -787,14 +781,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
prev.map((item) => ({
|
||||
...item,
|
||||
[fieldName]: value,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
screenInfo={{
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
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 { getNodeFlows, NodeFlow } from "@/lib/api/nodeFlows";
|
||||
|
||||
|
|
@ -14,11 +17,22 @@ interface ImprovedButtonControlConfigPanelProps {
|
|||
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> = ({
|
||||
component,
|
||||
|
|
@ -27,6 +41,9 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
|||
const config = component.webTypeConfig || {};
|
||||
const dataflowConfig = config.dataflowConfig || {};
|
||||
|
||||
// 다중 제어 설정 (배열)
|
||||
const flowControls: FlowControlConfig[] = dataflowConfig.flowControls || [];
|
||||
|
||||
// 🔥 State 관리
|
||||
const [flows, setFlows] = useState<NodeFlow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -58,24 +75,118 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
|||
};
|
||||
|
||||
/**
|
||||
* 🔥 플로우 선택 핸들러
|
||||
* 🔥 제어 추가
|
||||
*/
|
||||
const handleFlowSelect = (flowId: string) => {
|
||||
const selectedFlow = flows.find((f) => f.flowId.toString() === flowId);
|
||||
if (selectedFlow) {
|
||||
// 전체 dataflowConfig 업데이트 (selectedDiagramId 포함)
|
||||
onUpdateProperty("webTypeConfig.dataflowConfig", {
|
||||
...dataflowConfig,
|
||||
selectedDiagramId: selectedFlow.flowId, // 백엔드에서 사용
|
||||
selectedRelationshipId: null, // 노드 플로우는 관계 ID 불필요
|
||||
flowConfig: {
|
||||
flowId: selectedFlow.flowId,
|
||||
flowName: selectedFlow.flowName,
|
||||
executionTiming: "before", // 기본값
|
||||
contextData: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
const handleAddControl = useCallback(() => {
|
||||
const newControl: FlowControlConfig = {
|
||||
id: `control_${Date.now()}`,
|
||||
flowId: 0,
|
||||
flowName: "",
|
||||
executionTiming: "after",
|
||||
order: flowControls.length + 1,
|
||||
};
|
||||
|
||||
const updatedControls = [...flowControls, newControl];
|
||||
updateFlowControls(updatedControls);
|
||||
}, [flowControls]);
|
||||
|
||||
/**
|
||||
* 🔥 제어 삭제
|
||||
*/
|
||||
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 (
|
||||
|
|
@ -98,32 +209,57 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
|||
{/* 🔥 제어관리가 활성화된 경우에만 설정 표시 */}
|
||||
{config.enableDataflowControl && (
|
||||
<div className="space-y-4">
|
||||
<FlowSelector
|
||||
flows={flows}
|
||||
selectedFlowId={dataflowConfig.flowConfig?.flowId}
|
||||
onSelect={handleFlowSelect}
|
||||
loading={loading}
|
||||
/>
|
||||
{/* 제어 목록 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Workflow className="h-4 w-4 text-green-600" />
|
||||
<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">
|
||||
<Separator />
|
||||
<ExecutionTimingSelector
|
||||
value={dataflowConfig.flowConfig.executionTiming}
|
||||
onChange={(timing) =>
|
||||
onUpdateProperty("webTypeConfig.dataflowConfig.flowConfig.executionTiming", timing)
|
||||
}
|
||||
/>
|
||||
{/* 제어 목록 */}
|
||||
{flowControls.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-6 text-center">
|
||||
<Workflow className="mx-auto h-8 w-8 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-500">등록된 제어가 없습니다</p>
|
||||
<Button variant="outline" size="sm" onClick={handleAddControl} className="mt-3">
|
||||
<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">
|
||||
<Info className="mt-0.5 h-4 w-4 text-green-600" />
|
||||
<div className="text-xs text-green-800">
|
||||
<p className="font-medium">노드 플로우 실행 정보:</p>
|
||||
<p className="mt-1">선택한 플로우의 모든 노드가 순차적/병렬로 실행됩니다.</p>
|
||||
<p className="mt-1">• 독립 트랜잭션: 각 액션은 독립적으로 커밋/롤백</p>
|
||||
<p>• 연쇄 중단: 부모 노드 실패 시 자식 노드 스킵</p>
|
||||
</div>
|
||||
{/* 안내 메시지 */}
|
||||
{flowControls.length > 0 && (
|
||||
<div className="rounded bg-blue-50 p-3">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Info className="mt-0.5 h-4 w-4 text-blue-600" />
|
||||
<div className="text-xs text-blue-800">
|
||||
<p className="font-medium">다중 제어 실행 정보:</p>
|
||||
<p className="mt-1">• 제어는 위에서 아래 순서대로 순차 실행됩니다</p>
|
||||
<p>• 각 제어는 독립 트랜잭션으로 처리됩니다</p>
|
||||
<p>• 이전 제어 실패 시 다음 제어는 실행되지 않습니다</p>
|
||||
</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[];
|
||||
selectedFlowId?: number;
|
||||
onSelect: (flowId: string) => void;
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Workflow className="h-4 w-4 text-green-600" />
|
||||
<Label>실행할 노드 플로우 선택</Label>
|
||||
</div>
|
||||
<Card className="p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
{/* 순서 표시 및 이동 버튼 */}
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<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>
|
||||
<SelectValue placeholder="플로우를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">플로우 목록을 불러오는 중...</div>
|
||||
) : flows.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">
|
||||
<p>사용 가능한 플로우가 없습니다</p>
|
||||
<p className="mt-2 text-xs">노드 편집기에서 플로우를 먼저 생성하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
flows.map((flow) => (
|
||||
<SelectItem key={flow.flowId} value={flow.flowId.toString()}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{flow.flowName}</span>
|
||||
{flow.flowDescription && (
|
||||
<span className="text-muted-foreground text-xs">{flow.flowDescription}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 플로우 선택 및 설정 */}
|
||||
<div className="flex-1 space-y-2">
|
||||
{/* 플로우 선택 */}
|
||||
<Select value={control.flowId > 0 ? control.flowId.toString() : ""} onValueChange={onFlowSelect}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="플로우를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loading ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">로딩 중...</div>
|
||||
) : flows.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">플로우가 없습니다</div>
|
||||
) : (
|
||||
flows.map((flow) => (
|
||||
<SelectItem key={flow.flowId} value={flow.flowId.toString()}>
|
||||
<span className="text-xs">{flow.flowName}</span>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</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>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
<SelectItem value="after">
|
||||
<span className="text-xs">After (사후 실행)</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="replace">
|
||||
<span className="text-xs">Replace (대체 실행)</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
/**
|
||||
* 🔥 실행 타이밍 선택 컴포넌트
|
||||
*/
|
||||
const ExecutionTimingSelector: React.FC<{
|
||||
value: string;
|
||||
onChange: (timing: "before" | "after" | "replace") => void;
|
||||
}> = ({ value, onChange }) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4 text-orange-600" />
|
||||
<Label>실행 타이밍</Label>
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-600"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -192,3 +192,4 @@ export function applyAutoFillToFormData(
|
|||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
|||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||
selectedRows?: any[];
|
||||
selectedRowsData?: any[];
|
||||
|
||||
|
||||
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
|
|
@ -57,10 +57,10 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
|||
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
||||
flowSelectedData?: any[];
|
||||
flowSelectedStepId?: number | null;
|
||||
|
||||
|
||||
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
|
||||
allComponents?: any[];
|
||||
|
||||
|
||||
// 🆕 부모창에서 전달된 그룹 데이터 (모달에서 부모 데이터 접근용)
|
||||
groupedData?: Record<string, any>[];
|
||||
}
|
||||
|
|
@ -109,11 +109,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
||||
// 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동)
|
||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||
|
||||
|
||||
// 🆕 tableName이 props로 전달되지 않으면 ScreenContext에서 가져오기
|
||||
const effectiveTableName = tableName || screenContext?.tableName;
|
||||
const effectiveScreenId = screenId || screenContext?.screenId;
|
||||
|
||||
|
||||
// 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출)
|
||||
const propsOnSave = (props as any).onSave as (() => Promise<void>) | undefined;
|
||||
const finalOnSave = onSave || propsOnSave;
|
||||
|
|
@ -169,10 +169,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
if (!shouldFetchStatus) return;
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (!isMounted) return;
|
||||
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`/table-management/tables/${statusTableName}/data`, {
|
||||
page: 1,
|
||||
|
|
@ -180,12 +180,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
search: { [statusKeyField]: userId },
|
||||
autoFilter: true,
|
||||
});
|
||||
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
|
||||
const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
|
||||
const firstRow = Array.isArray(rows) ? rows[0] : null;
|
||||
|
||||
|
||||
if (response.data?.success && firstRow) {
|
||||
const newStatus = firstRow[statusFieldName];
|
||||
if (newStatus !== vehicleStatus) {
|
||||
|
|
@ -206,10 +206,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
// 즉시 실행
|
||||
setStatusLoading(true);
|
||||
fetchStatus();
|
||||
|
||||
|
||||
// 2초마다 갱신
|
||||
const interval = setInterval(fetchStatus, 2000);
|
||||
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearInterval(interval);
|
||||
|
|
@ -219,22 +219,22 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
// 버튼 비활성화 조건 계산
|
||||
const isOperationButtonDisabled = useMemo(() => {
|
||||
const actionConfig = component.componentConfig?.action;
|
||||
|
||||
|
||||
if (actionConfig?.type !== "operation_control") return false;
|
||||
|
||||
// 1. 출발지/도착지 필수 체크
|
||||
if (actionConfig?.requireLocationFields) {
|
||||
const departureField = actionConfig.trackingDepartureField || "departure";
|
||||
const destinationField = actionConfig.trackingArrivalField || "destination";
|
||||
|
||||
|
||||
const departure = formData?.[departureField];
|
||||
const destination = formData?.[destinationField];
|
||||
|
||||
// console.log("🔍 [ButtonPrimary] 출발지/도착지 체크:", {
|
||||
// departureField, destinationField, departure, destination,
|
||||
// buttonLabel: component.label
|
||||
|
||||
// console.log("🔍 [ButtonPrimary] 출발지/도착지 체크:", {
|
||||
// departureField, destinationField, departure, destination,
|
||||
// buttonLabel: component.label
|
||||
// });
|
||||
|
||||
|
||||
if (!departure || departure === "" || !destination || destination === "") {
|
||||
// console.log("🚫 [ButtonPrimary] 출발지/도착지 미선택 → 비활성화:", component.label);
|
||||
return true;
|
||||
|
|
@ -246,20 +246,20 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
const statusField = actionConfig.statusCheckField || "status";
|
||||
// API 조회 결과를 우선 사용 (실시간 DB 상태 반영)
|
||||
const currentStatus = vehicleStatus || formData?.[statusField];
|
||||
|
||||
|
||||
const conditionType = actionConfig.statusConditionType || "enableOn";
|
||||
const conditionValues = (actionConfig.statusConditionValues || "")
|
||||
.split(",")
|
||||
.map((v: string) => v.trim())
|
||||
.filter((v: string) => v);
|
||||
|
||||
// console.log("🔍 [ButtonPrimary] 상태 조건 체크:", {
|
||||
// console.log("🔍 [ButtonPrimary] 상태 조건 체크:", {
|
||||
// statusField,
|
||||
// formDataStatus: formData?.[statusField],
|
||||
// apiStatus: vehicleStatus,
|
||||
// currentStatus,
|
||||
// conditionType,
|
||||
// conditionValues,
|
||||
// currentStatus,
|
||||
// conditionType,
|
||||
// conditionValues,
|
||||
// buttonLabel: component.label,
|
||||
// });
|
||||
|
||||
|
|
@ -274,7 +274,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
// console.log("🚫 [ButtonPrimary] 상태값 없음 → 비활성화:", component.label);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
if (conditionValues.length > 0) {
|
||||
if (conditionType === "enableOn") {
|
||||
// 이 상태일 때만 활성화
|
||||
|
|
@ -539,7 +539,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
*/
|
||||
const handleTransferDataAction = async (actionConfig: any) => {
|
||||
const dataTransferConfig = actionConfig.dataTransfer;
|
||||
|
||||
|
||||
if (!dataTransferConfig) {
|
||||
toast.error("데이터 전달 설정이 없습니다.");
|
||||
return;
|
||||
|
|
@ -553,15 +553,15 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
try {
|
||||
// 1. 소스 컴포넌트에서 데이터 가져오기
|
||||
let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
|
||||
|
||||
|
||||
// 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색
|
||||
// (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응)
|
||||
if (!sourceProvider) {
|
||||
console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`);
|
||||
console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`);
|
||||
|
||||
|
||||
const allProviders = screenContext.getAllDataProviders();
|
||||
|
||||
|
||||
// 테이블 리스트 우선 탐색
|
||||
for (const [id, provider] of allProviders) {
|
||||
if (provider.componentType === "table-list") {
|
||||
|
|
@ -570,16 +570,18 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 테이블 리스트가 없으면 첫 번째 DataProvider 사용
|
||||
if (!sourceProvider && allProviders.size > 0) {
|
||||
const firstEntry = allProviders.entries().next().value;
|
||||
if (firstEntry) {
|
||||
sourceProvider = firstEntry[1];
|
||||
console.log(`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`);
|
||||
console.log(
|
||||
`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!sourceProvider) {
|
||||
toast.error("데이터를 제공할 수 있는 컴포넌트를 찾을 수 없습니다.");
|
||||
return;
|
||||
|
|
@ -587,12 +589,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
}
|
||||
|
||||
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) });
|
||||
|
||||
|
||||
if (!sourceData || sourceData.length === 0) {
|
||||
toast.warning("선택된 데이터가 없습니다.");
|
||||
return;
|
||||
|
|
@ -600,31 +602,32 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
// 1.5. 추가 데이터 소스 처리 (예: 조건부 컨테이너의 카테고리 값)
|
||||
let additionalData: Record<string, any> = {};
|
||||
|
||||
|
||||
// 방법 1: additionalSources 설정에서 가져오기
|
||||
if (dataTransferConfig.additionalSources && Array.isArray(dataTransferConfig.additionalSources)) {
|
||||
for (const additionalSource of dataTransferConfig.additionalSources) {
|
||||
const additionalProvider = screenContext.getDataProvider(additionalSource.componentId);
|
||||
|
||||
|
||||
if (additionalProvider) {
|
||||
const additionalValues = additionalProvider.getSelectedData();
|
||||
|
||||
|
||||
if (additionalValues && additionalValues.length > 0) {
|
||||
// 첫 번째 값 사용 (조건부 컨테이너는 항상 1개)
|
||||
const firstValue = additionalValues[0];
|
||||
|
||||
|
||||
// fieldName이 지정되어 있으면 그 필드만 추출
|
||||
if (additionalSource.fieldName) {
|
||||
additionalData[additionalSource.fieldName] = firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
|
||||
additionalData[additionalSource.fieldName] =
|
||||
firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
|
||||
} else {
|
||||
// fieldName이 없으면 전체 객체 병합
|
||||
additionalData = { ...additionalData, ...firstValue };
|
||||
}
|
||||
|
||||
|
||||
console.log("📦 추가 데이터 수집 (additionalSources):", {
|
||||
sourceId: additionalSource.componentId,
|
||||
fieldName: additionalSource.fieldName,
|
||||
value: additionalData[additionalSource.fieldName || 'all'],
|
||||
value: additionalData[additionalSource.fieldName || "all"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -639,7 +642,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
const conditionalValue = formData.__conditionalContainerValue;
|
||||
const conditionalLabel = formData.__conditionalContainerLabel;
|
||||
const controlField = formData.__conditionalContainerControlField; // 🆕 제어 필드명 직접 사용
|
||||
|
||||
|
||||
// 🆕 controlField가 있으면 그것을 필드명으로 사용 (자동 매핑!)
|
||||
if (controlField) {
|
||||
additionalData[controlField] = conditionalValue;
|
||||
|
|
@ -651,7 +654,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
} else {
|
||||
// controlField가 없으면 기존 방식: formData에서 같은 값을 가진 키 찾기
|
||||
for (const [key, value] of Object.entries(formData)) {
|
||||
if (value === conditionalValue && !key.startsWith('__')) {
|
||||
if (value === conditionalValue && !key.startsWith("__")) {
|
||||
additionalData[key] = conditionalValue;
|
||||
console.log("📦 조건부 컨테이너 값 자동 포함:", {
|
||||
fieldName: key,
|
||||
|
|
@ -661,12 +664,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 못 찾았으면 기본 필드명 사용
|
||||
if (!Object.keys(additionalData).some(k => !k.startsWith('__'))) {
|
||||
additionalData['condition_type'] = conditionalValue;
|
||||
if (!Object.keys(additionalData).some((k) => !k.startsWith("__"))) {
|
||||
additionalData["condition_type"] = conditionalValue;
|
||||
console.log("📦 조건부 컨테이너 값 (기본 필드명):", {
|
||||
fieldName: 'condition_type',
|
||||
fieldName: "condition_type",
|
||||
value: conditionalValue,
|
||||
});
|
||||
}
|
||||
|
|
@ -698,7 +701,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
// 4. 매핑 규칙 적용 + 추가 데이터 병합
|
||||
const mappedData = sourceData.map((row) => {
|
||||
const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []);
|
||||
|
||||
|
||||
// 추가 데이터를 모든 행에 포함
|
||||
return {
|
||||
...mappedRow,
|
||||
|
|
@ -718,7 +721,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
if (dataTransferConfig.targetType === "component") {
|
||||
// 같은 화면의 컴포넌트로 전달
|
||||
const targetReceiver = screenContext.getDataReceiver(dataTransferConfig.targetComponentId);
|
||||
|
||||
|
||||
if (!targetReceiver) {
|
||||
toast.error(`타겟 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.targetComponentId}`);
|
||||
return;
|
||||
|
|
@ -730,7 +733,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
mode: dataTransferConfig.mode || "append",
|
||||
mappingRules: dataTransferConfig.mappingRules || [],
|
||||
});
|
||||
|
||||
|
||||
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
|
||||
} else if (dataTransferConfig.targetType === "splitPanel") {
|
||||
// 🆕 분할 패널의 반대편 화면으로 전달
|
||||
|
|
@ -738,17 +741,18 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 🆕 useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동)
|
||||
// screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로,
|
||||
// screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로,
|
||||
// SplitPanelPositionProvider로 전달된 위치를 우선 사용
|
||||
const currentPosition = splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null);
|
||||
|
||||
const currentPosition =
|
||||
splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null);
|
||||
|
||||
if (!currentPosition) {
|
||||
toast.error("분할 패널 내 위치를 확인할 수 없습니다. screenId: " + screenId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
console.log("📦 분할 패널 데이터 전달:", {
|
||||
currentPosition,
|
||||
splitPanelPositionFromHook: splitPanelPosition,
|
||||
|
|
@ -756,14 +760,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
leftScreenId: splitPanelContext.leftScreenId,
|
||||
rightScreenId: splitPanelContext.rightScreenId,
|
||||
});
|
||||
|
||||
|
||||
const result = await splitPanelContext.transferToOtherSide(
|
||||
currentPosition,
|
||||
mappedData,
|
||||
dataTransferConfig.targetComponentId, // 특정 컴포넌트 지정 (선택사항)
|
||||
dataTransferConfig.mode || "append"
|
||||
dataTransferConfig.mode || "append",
|
||||
);
|
||||
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
|
|
@ -782,7 +786,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
if (dataTransferConfig.clearAfterTransfer) {
|
||||
sourceProvider.clearSelection();
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("❌ 데이터 전달 실패:", error);
|
||||
toast.error(error.message || "데이터 전달 중 오류가 발생했습니다.");
|
||||
|
|
@ -816,16 +819,20 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
// 2. groupedData (부모창에서 모달로 전달된 데이터)
|
||||
// 3. modalDataStore (분할 패널 등에서 선택한 데이터)
|
||||
let effectiveSelectedRowsData = selectedRowsData;
|
||||
|
||||
|
||||
// groupedData가 있으면 우선 사용 (모달에서 부모 데이터 접근)
|
||||
if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && groupedData && groupedData.length > 0) {
|
||||
if (
|
||||
(!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) &&
|
||||
groupedData &&
|
||||
groupedData.length > 0
|
||||
) {
|
||||
effectiveSelectedRowsData = groupedData;
|
||||
console.log("🔗 [ButtonPrimaryComponent] groupedData에서 부모창 데이터 가져옴:", {
|
||||
count: groupedData.length,
|
||||
data: groupedData,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
|
||||
if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && effectiveTableName) {
|
||||
try {
|
||||
|
|
@ -833,11 +840,17 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
const dataRegistry = useModalDataStore.getState().dataRegistry;
|
||||
const modalData = dataRegistry[effectiveTableName];
|
||||
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에서 선택된 데이터 가져옴:", {
|
||||
tableName: effectiveTableName,
|
||||
count: modalData.length,
|
||||
data: modalData,
|
||||
rawData: modalData,
|
||||
extractedData: effectiveSelectedRowsData,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -847,7 +860,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
// 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단
|
||||
const hasDataToDelete =
|
||||
(effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0);
|
||||
(effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) ||
|
||||
(flowSelectedData && flowSelectedData.length > 0);
|
||||
|
||||
if (processedConfig.action.type === "delete" && !hasDataToDelete) {
|
||||
toast.warning("삭제할 항목을 먼저 선택해주세요.");
|
||||
|
|
@ -1064,15 +1078,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
// 🔧 크기에 따른 패딩 조정
|
||||
padding:
|
||||
componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
|
||||
padding: componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
|
||||
margin: "0",
|
||||
lineHeight: "1.25",
|
||||
boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외)
|
||||
...(component.style ? Object.fromEntries(
|
||||
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
|
||||
) : {}),
|
||||
...(component.style
|
||||
? Object.fromEntries(Object.entries(component.style).filter(([key]) => key !== "width" && key !== "height"))
|
||||
: {}),
|
||||
};
|
||||
|
||||
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";
|
||||
|
|
@ -1094,7 +1107,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
<button
|
||||
type={componentConfig.actionType || "button"}
|
||||
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}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
|
|
|
|||
|
|
@ -715,8 +715,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const colName = typeof col === "string" ? col : col.name || col.columnName;
|
||||
if (colName && colName.includes(".")) {
|
||||
const [refTable, refColumn] = colName.split(".");
|
||||
// 소스 컬럼 추론 (item_info → item_code)
|
||||
const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
|
||||
// 소스 컬럼 추론 (item_info → item_code 또는 warehouse_info → warehouse_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(
|
||||
|
|
|
|||
|
|
@ -200,29 +200,49 @@ export function UniversalFormModalComponent({
|
|||
// 초기 데이터를 한 번만 캡처 (컴포넌트 마운트 시)
|
||||
const capturedInitialData = useRef<Record<string, any> | undefined>(undefined);
|
||||
const hasInitialized = useRef(false);
|
||||
// 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요)
|
||||
const lastInitializedId = useRef<string | undefined>(undefined);
|
||||
|
||||
// 초기화 - 최초 마운트 시에만 실행
|
||||
// 초기화 - 최초 마운트 시 또는 initialData의 ID가 변경되었을 때 실행
|
||||
useEffect(() => {
|
||||
// 이미 초기화되었으면 스킵
|
||||
if (hasInitialized.current) {
|
||||
// initialData에서 ID 값 추출 (id, ID, objid 등)
|
||||
const currentId = initialData?.id || initialData?.ID || initialData?.objid;
|
||||
const currentIdString = currentId !== undefined ? String(currentId) : undefined;
|
||||
|
||||
// 이미 초기화되었고, ID가 동일하면 스킵
|
||||
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
|
||||
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 캡처 (이후 변경되어도 이 값 사용)
|
||||
if (initialData && Object.keys(initialData).length > 0) {
|
||||
capturedInitialData.current = JSON.parse(JSON.stringify(initialData)); // 깊은 복사
|
||||
lastInitializedId.current = currentIdString;
|
||||
console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current);
|
||||
}
|
||||
|
||||
hasInitialized.current = true;
|
||||
initializeForm();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 빈 의존성 배열 - 마운트 시 한 번만 실행
|
||||
}, [initialData?.id, initialData?.ID, initialData?.objid]); // ID 값 변경 시 재초기화
|
||||
|
||||
// config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외
|
||||
useEffect(() => {
|
||||
if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵
|
||||
|
||||
console.log('[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)');
|
||||
console.log("[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)");
|
||||
// initializeForm(); // 주석 처리 - config 변경 시 재초기화 안 함 (채번 중복 방지)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
|
|
@ -230,7 +250,7 @@ export function UniversalFormModalComponent({
|
|||
// 컴포넌트 unmount 시 채번 플래그 초기화
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
console.log('[채번] 컴포넌트 unmount - 플래그 초기화');
|
||||
console.log("[채번] 컴포넌트 unmount - 플래그 초기화");
|
||||
numberingGeneratedRef.current = false;
|
||||
isGeneratingRef.current = false;
|
||||
};
|
||||
|
|
@ -241,7 +261,7 @@ export function UniversalFormModalComponent({
|
|||
useEffect(() => {
|
||||
const handleBeforeFormSave = (event: Event) => {
|
||||
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
|
||||
|
||||
|
||||
// 설정에 정의된 필드 columnName 목록 수집
|
||||
const configuredFields = new Set<string>();
|
||||
config.sections.forEach((section) => {
|
||||
|
|
@ -251,10 +271,10 @@ export function UniversalFormModalComponent({
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
|
||||
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
|
||||
|
||||
|
||||
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
|
||||
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
|
||||
// (UniversalFormModal이 해당 필드의 주인이므로)
|
||||
|
|
@ -262,7 +282,7 @@ export function UniversalFormModalComponent({
|
|||
// 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합
|
||||
const isConfiguredField = configuredFields.has(key);
|
||||
const isNumberingRuleId = key.endsWith("_numberingRuleId");
|
||||
|
||||
|
||||
if (isConfiguredField || isNumberingRuleId) {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
event.detail.formData[key] = value;
|
||||
|
|
@ -270,7 +290,7 @@ export function UniversalFormModalComponent({
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 반복 섹션 데이터도 병합 (필요한 경우)
|
||||
if (Object.keys(repeatSections).length > 0) {
|
||||
for (const [sectionId, items] of Object.entries(repeatSections)) {
|
||||
|
|
@ -280,9 +300,9 @@ export function UniversalFormModalComponent({
|
|||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
||||
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
||||
};
|
||||
|
|
@ -316,11 +336,18 @@ export function UniversalFormModalComponent({
|
|||
|
||||
// 폼 초기화
|
||||
const initializeForm = useCallback(async () => {
|
||||
console.log('[initializeForm] 시작');
|
||||
|
||||
console.log("[initializeForm] 시작");
|
||||
|
||||
// 캡처된 initialData 사용 (props로 전달된 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 newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {};
|
||||
const newCollapsed = new Set<string>();
|
||||
|
|
@ -368,9 +395,9 @@ export function UniversalFormModalComponent({
|
|||
setOriginalData(effectiveInitialData || {});
|
||||
|
||||
// 채번규칙 자동 생성
|
||||
console.log('[initializeForm] generateNumberingValues 호출');
|
||||
console.log("[initializeForm] generateNumberingValues 호출");
|
||||
await generateNumberingValues(newFormData);
|
||||
console.log('[initializeForm] 완료');
|
||||
console.log("[initializeForm] 완료");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
|
||||
|
||||
|
|
@ -391,23 +418,23 @@ export function UniversalFormModalComponent({
|
|||
// 채번규칙 자동 생성 (중복 호출 방지)
|
||||
const numberingGeneratedRef = useRef(false);
|
||||
const isGeneratingRef = useRef(false); // 진행 중 플래그 추가
|
||||
|
||||
|
||||
const generateNumberingValues = useCallback(
|
||||
async (currentFormData: FormDataState) => {
|
||||
// 이미 생성되었거나 진행 중이면 스킵
|
||||
if (numberingGeneratedRef.current) {
|
||||
console.log('[채번] 이미 생성됨 - 스킵');
|
||||
console.log("[채번] 이미 생성됨 - 스킵");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (isGeneratingRef.current) {
|
||||
console.log('[채번] 생성 진행 중 - 스킵');
|
||||
console.log("[채번] 생성 진행 중 - 스킵");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
isGeneratingRef.current = true; // 진행 중 표시
|
||||
console.log('[채번] 미리보기 생성 시작');
|
||||
|
||||
console.log("[채번] 생성 시작");
|
||||
|
||||
const updatedData = { ...currentFormData };
|
||||
let hasChanges = false;
|
||||
|
||||
|
|
@ -427,21 +454,23 @@ export function UniversalFormModalComponent({
|
|||
const response = await previewNumberingCode(field.numberingRule.ruleId);
|
||||
if (response.success && response.data?.generatedCode) {
|
||||
updatedData[field.columnName] = response.data.generatedCode;
|
||||
|
||||
|
||||
// 저장 시 실제 할당을 위해 ruleId 저장 (TextInput과 동일한 키 형식)
|
||||
const ruleIdKey = `${field.columnName}_numberingRuleId`;
|
||||
updatedData[ruleIdKey] = field.numberingRule.ruleId;
|
||||
|
||||
|
||||
hasChanges = 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}`);
|
||||
|
||||
|
||||
// 부모 컴포넌트에도 ruleId 전달 (ModalRepeaterTable → ScreenModal)
|
||||
if (onChange) {
|
||||
onChange({
|
||||
onChange({
|
||||
...updatedData,
|
||||
[ruleIdKey]: field.numberingRule.ruleId
|
||||
[ruleIdKey]: field.numberingRule.ruleId,
|
||||
});
|
||||
console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`);
|
||||
}
|
||||
|
|
@ -454,7 +483,7 @@ export function UniversalFormModalComponent({
|
|||
}
|
||||
|
||||
isGeneratingRef.current = false; // 진행 완료
|
||||
|
||||
|
||||
if (hasChanges) {
|
||||
setFormData(updatedData);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2670,6 +2670,7 @@ export class ButtonActionExecutor {
|
|||
|
||||
/**
|
||||
* 저장 후 제어 실행 (After Timing)
|
||||
* 다중 제어 순차 실행 지원
|
||||
*/
|
||||
private static async executeAfterSaveControl(
|
||||
config: ButtonActionConfig,
|
||||
|
|
@ -2681,12 +2682,6 @@ export class ButtonActionExecutor {
|
|||
dataflowTiming: config.dataflowTiming,
|
||||
});
|
||||
|
||||
// dataflowTiming이 'after'가 아니면 실행하지 않음
|
||||
if (config.dataflowTiming && config.dataflowTiming !== "after") {
|
||||
console.log("⏭️ dataflowTiming이 'after'가 아니므로 제어 실행 건너뜀:", config.dataflowTiming);
|
||||
return;
|
||||
}
|
||||
|
||||
// 제어 데이터 소스 결정
|
||||
let controlDataSource = config.dataflowConfig?.controlDataSource;
|
||||
if (!controlDataSource) {
|
||||
|
|
@ -2700,9 +2695,117 @@ export class ButtonActionExecutor {
|
|||
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가 있는 경우)
|
||||
const hasFlowConfig = config.dataflowConfig?.flowConfig && config.dataflowConfig.flowConfig.flowId;
|
||||
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);
|
||||
|
||||
const { flowId } = config.dataflowConfig.flowConfig;
|
||||
|
|
|
|||
|
|
@ -1684,3 +1684,4 @@ const 출고등록_설정: ScreenSplitPanel = {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -531,3 +531,4 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -518,3 +518,4 @@ function ScreenViewPage() {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue