Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary, ; especially if it merges an updated upstream into a topic branch. ; ; Lines starting with ';' will be ignored, and an empty message aborts ; the commit.
This commit is contained in:
commit
62226918a7
|
|
@ -2282,6 +2282,7 @@ export class NodeFlowExecutionService {
|
||||||
UPDATE ${targetTable}
|
UPDATE ${targetTable}
|
||||||
SET ${setClauses.join(", ")}
|
SET ${setClauses.join(", ")}
|
||||||
WHERE ${updateWhereConditions}
|
WHERE ${updateWhereConditions}
|
||||||
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
logger.info(`🔄 UPDATE 실행:`, {
|
logger.info(`🔄 UPDATE 실행:`, {
|
||||||
|
|
@ -2292,8 +2293,14 @@ export class NodeFlowExecutionService {
|
||||||
values: updateValues,
|
values: updateValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
await txClient.query(updateSql, updateValues);
|
const updateResult = await txClient.query(updateSql, updateValues);
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
|
|
||||||
|
// 🆕 UPDATE 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능)
|
||||||
|
if (updateResult.rows && updateResult.rows[0]) {
|
||||||
|
Object.assign(data, updateResult.rows[0]);
|
||||||
|
logger.info(` 📦 UPDATE 결과 병합: id=${updateResult.rows[0].id}`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 3-B. 없으면 INSERT
|
// 3-B. 없으면 INSERT
|
||||||
const columns: string[] = [];
|
const columns: string[] = [];
|
||||||
|
|
@ -2340,6 +2347,7 @@ export class NodeFlowExecutionService {
|
||||||
const insertSql = `
|
const insertSql = `
|
||||||
INSERT INTO ${targetTable} (${columns.join(", ")})
|
INSERT INTO ${targetTable} (${columns.join(", ")})
|
||||||
VALUES (${placeholders})
|
VALUES (${placeholders})
|
||||||
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
logger.info(`➕ INSERT 실행:`, {
|
logger.info(`➕ INSERT 실행:`, {
|
||||||
|
|
@ -2348,8 +2356,14 @@ export class NodeFlowExecutionService {
|
||||||
conflictKeyValues,
|
conflictKeyValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
await txClient.query(insertSql, values);
|
const insertResult = await txClient.query(insertSql, values);
|
||||||
insertedCount++;
|
insertedCount++;
|
||||||
|
|
||||||
|
// 🆕 INSERT 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능)
|
||||||
|
if (insertResult.rows && insertResult.rows[0]) {
|
||||||
|
Object.assign(data, insertResult.rows[0]);
|
||||||
|
logger.info(` 📦 INSERT 결과 병합: id=${insertResult.rows[0].id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2357,11 +2371,10 @@ export class NodeFlowExecutionService {
|
||||||
`✅ UPSERT 완료 (내부 DB): ${targetTable}, INSERT ${insertedCount}건, UPDATE ${updatedCount}건`
|
`✅ UPSERT 완료 (내부 DB): ${targetTable}, INSERT ${insertedCount}건, UPDATE ${updatedCount}건`
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
// 🔥 다음 노드에 전달할 데이터 반환
|
||||||
insertedCount,
|
// dataArray에는 Object.assign으로 UPSERT 결과(id 등)가 이미 병합되어 있음
|
||||||
updatedCount,
|
// 카운트 정보도 함께 반환하여 기존 호환성 유지
|
||||||
totalCount: insertedCount + updatedCount,
|
return dataArray;
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성
|
// 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성
|
||||||
|
|
@ -2707,28 +2720,48 @@ export class NodeFlowExecutionService {
|
||||||
const trueData: any[] = [];
|
const trueData: any[] = [];
|
||||||
const falseData: any[] = [];
|
const falseData: any[] = [];
|
||||||
|
|
||||||
inputData.forEach((item: any) => {
|
// 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기)
|
||||||
const results = conditions.map((condition: any) => {
|
for (const item of inputData) {
|
||||||
|
const results: boolean[] = [];
|
||||||
|
|
||||||
|
for (const condition of conditions) {
|
||||||
const fieldValue = item[condition.field];
|
const fieldValue = item[condition.field];
|
||||||
|
|
||||||
let compareValue = condition.value;
|
// EXISTS 계열 연산자 처리
|
||||||
if (condition.valueType === "field") {
|
if (
|
||||||
compareValue = item[condition.value];
|
condition.operator === "EXISTS_IN" ||
|
||||||
|
condition.operator === "NOT_EXISTS_IN"
|
||||||
|
) {
|
||||||
|
const existsResult = await this.evaluateExistsCondition(
|
||||||
|
fieldValue,
|
||||||
|
condition.operator,
|
||||||
|
condition.lookupTable,
|
||||||
|
condition.lookupField,
|
||||||
|
context.buttonContext?.companyCode
|
||||||
|
);
|
||||||
|
results.push(existsResult);
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.info(
|
// 일반 연산자 처리
|
||||||
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
let compareValue = condition.value;
|
||||||
|
if (condition.valueType === "field") {
|
||||||
|
compareValue = item[condition.value];
|
||||||
|
logger.info(
|
||||||
|
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(
|
||||||
|
this.evaluateCondition(fieldValue, condition.operator, compareValue)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return this.evaluateCondition(
|
|
||||||
fieldValue,
|
|
||||||
condition.operator,
|
|
||||||
compareValue
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result =
|
const result =
|
||||||
logic === "OR"
|
logic === "OR"
|
||||||
|
|
@ -2740,7 +2773,7 @@ export class NodeFlowExecutionService {
|
||||||
} else {
|
} else {
|
||||||
falseData.push(item);
|
falseData.push(item);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔍 조건 필터링 결과: TRUE ${trueData.length}건 / FALSE ${falseData.length}건 (${logic} 로직)`
|
`🔍 조건 필터링 결과: TRUE ${trueData.length}건 / FALSE ${falseData.length}건 (${logic} 로직)`
|
||||||
|
|
@ -2755,27 +2788,46 @@ export class NodeFlowExecutionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 단일 객체인 경우
|
// 단일 객체인 경우
|
||||||
const results = conditions.map((condition: any) => {
|
const results: boolean[] = [];
|
||||||
|
|
||||||
|
for (const condition of conditions) {
|
||||||
const fieldValue = inputData[condition.field];
|
const fieldValue = inputData[condition.field];
|
||||||
|
|
||||||
let compareValue = condition.value;
|
// EXISTS 계열 연산자 처리
|
||||||
if (condition.valueType === "field") {
|
if (
|
||||||
compareValue = inputData[condition.value];
|
condition.operator === "EXISTS_IN" ||
|
||||||
|
condition.operator === "NOT_EXISTS_IN"
|
||||||
|
) {
|
||||||
|
const existsResult = await this.evaluateExistsCondition(
|
||||||
|
fieldValue,
|
||||||
|
condition.operator,
|
||||||
|
condition.lookupTable,
|
||||||
|
condition.lookupField,
|
||||||
|
context.buttonContext?.companyCode
|
||||||
|
);
|
||||||
|
results.push(existsResult);
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.info(
|
// 일반 연산자 처리
|
||||||
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
let compareValue = condition.value;
|
||||||
|
if (condition.valueType === "field") {
|
||||||
|
compareValue = inputData[condition.value];
|
||||||
|
logger.info(
|
||||||
|
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(
|
||||||
|
this.evaluateCondition(fieldValue, condition.operator, compareValue)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return this.evaluateCondition(
|
|
||||||
fieldValue,
|
|
||||||
condition.operator,
|
|
||||||
compareValue
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result =
|
const result =
|
||||||
logic === "OR"
|
logic === "OR"
|
||||||
|
|
@ -2784,7 +2836,7 @@ export class NodeFlowExecutionService {
|
||||||
|
|
||||||
logger.info(`🔍 조건 평가 결과: ${result} (${logic} 로직)`);
|
logger.info(`🔍 조건 평가 결과: ${result} (${logic} 로직)`);
|
||||||
|
|
||||||
// ⚠️ 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요
|
// 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요
|
||||||
// 조건 결과를 저장하고, 원본 데이터는 항상 반환
|
// 조건 결과를 저장하고, 원본 데이터는 항상 반환
|
||||||
// 다음 노드에서 sourceHandle을 기반으로 필터링됨
|
// 다음 노드에서 sourceHandle을 기반으로 필터링됨
|
||||||
return {
|
return {
|
||||||
|
|
@ -2795,6 +2847,68 @@ export class NodeFlowExecutionService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EXISTS_IN / NOT_EXISTS_IN 조건 평가
|
||||||
|
* 다른 테이블에 값이 존재하는지 확인
|
||||||
|
*/
|
||||||
|
private static async evaluateExistsCondition(
|
||||||
|
fieldValue: any,
|
||||||
|
operator: string,
|
||||||
|
lookupTable: string,
|
||||||
|
lookupField: string,
|
||||||
|
companyCode?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!lookupTable || !lookupField) {
|
||||||
|
logger.warn("⚠️ EXISTS 조건: lookupTable 또는 lookupField가 없습니다");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
|
||||||
|
logger.info(
|
||||||
|
`⚠️ EXISTS 조건: 필드값이 비어있어 ${operator === "NOT_EXISTS_IN" ? "TRUE" : "FALSE"} 반환`
|
||||||
|
);
|
||||||
|
// 값이 비어있으면: EXISTS_IN은 false, NOT_EXISTS_IN은 true
|
||||||
|
return operator === "NOT_EXISTS_IN";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 멀티테넌시: company_code 필터 적용 여부 확인
|
||||||
|
// company_mng 테이블은 제외
|
||||||
|
const hasCompanyCode = lookupTable !== "company_mng" && companyCode;
|
||||||
|
|
||||||
|
let sql: string;
|
||||||
|
let params: any[];
|
||||||
|
|
||||||
|
if (hasCompanyCode) {
|
||||||
|
sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1 AND company_code = $2) as exists_result`;
|
||||||
|
params = [fieldValue, companyCode];
|
||||||
|
} else {
|
||||||
|
sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1) as exists_result`;
|
||||||
|
params = [fieldValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`🔍 EXISTS 쿼리: ${sql}, params: ${JSON.stringify(params)}`);
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
const existsInTable = result[0]?.exists_result === true;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🔍 EXISTS 결과: ${fieldValue}이(가) ${lookupTable}.${lookupField}에 ${existsInTable ? "존재함" : "존재하지 않음"}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// EXISTS_IN: 존재하면 true
|
||||||
|
// NOT_EXISTS_IN: 존재하지 않으면 true
|
||||||
|
if (operator === "EXISTS_IN") {
|
||||||
|
return existsInTable;
|
||||||
|
} else {
|
||||||
|
return !existsInTable;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`❌ EXISTS 조건 평가 실패: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WHERE 절 생성
|
* WHERE 절 생성
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,13 @@ const OPERATOR_LABELS: Record<string, string> = {
|
||||||
NOT_IN: "NOT IN",
|
NOT_IN: "NOT IN",
|
||||||
IS_NULL: "NULL",
|
IS_NULL: "NULL",
|
||||||
IS_NOT_NULL: "NOT NULL",
|
IS_NOT_NULL: "NOT NULL",
|
||||||
|
EXISTS_IN: "EXISTS IN",
|
||||||
|
NOT_EXISTS_IN: "NOT EXISTS IN",
|
||||||
|
};
|
||||||
|
|
||||||
|
// EXISTS 계열 연산자인지 확인
|
||||||
|
const isExistsOperator = (operator: string): boolean => {
|
||||||
|
return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeData>) => {
|
export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||||
|
|
@ -54,15 +61,31 @@ export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeDa
|
||||||
{idx > 0 && (
|
{idx > 0 && (
|
||||||
<div className="mb-1 text-center text-xs font-semibold text-yellow-600">{data.logic}</div>
|
<div className="mb-1 text-center text-xs font-semibold text-yellow-600">{data.logic}</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
<span className="font-mono text-gray-700">{condition.field}</span>
|
<span className="font-mono text-gray-700">{condition.field}</span>
|
||||||
<span className="rounded bg-yellow-200 px-1 py-0.5 text-yellow-800">
|
<span
|
||||||
|
className={`rounded px-1 py-0.5 ${
|
||||||
|
isExistsOperator(condition.operator)
|
||||||
|
? "bg-purple-200 text-purple-800"
|
||||||
|
: "bg-yellow-200 text-yellow-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{OPERATOR_LABELS[condition.operator] || condition.operator}
|
{OPERATOR_LABELS[condition.operator] || condition.operator}
|
||||||
</span>
|
</span>
|
||||||
{condition.value !== null && condition.value !== undefined && (
|
{/* EXISTS 연산자인 경우 테이블.필드 표시 */}
|
||||||
<span className="text-gray-600">
|
{isExistsOperator(condition.operator) ? (
|
||||||
{typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)}
|
<span className="text-purple-600">
|
||||||
|
{(condition as any).lookupTableLabel || (condition as any).lookupTable || "..."}
|
||||||
|
{(condition as any).lookupField && `.${(condition as any).lookupFieldLabel || (condition as any).lookupField}`}
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
// 일반 연산자인 경우 값 표시
|
||||||
|
condition.value !== null &&
|
||||||
|
condition.value !== undefined && (
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,18 @@
|
||||||
* 조건 분기 노드 속성 편집
|
* 조건 분기 노드 속성 편집
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2, Database, Search, Check, ChevronsUpDown } from "lucide-react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
import type { ConditionNodeData } from "@/types/node-editor";
|
import type { ConditionNodeData, ConditionOperator } from "@/types/node-editor";
|
||||||
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// 필드 정의
|
// 필드 정의
|
||||||
interface FieldDefinition {
|
interface FieldDefinition {
|
||||||
|
|
@ -20,6 +24,19 @@ interface FieldDefinition {
|
||||||
type?: string;
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 테이블 정보
|
||||||
|
interface TableInfo {
|
||||||
|
tableName: string;
|
||||||
|
tableLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블 컬럼 정보
|
||||||
|
interface ColumnInfo {
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
dataType: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ConditionPropertiesProps {
|
interface ConditionPropertiesProps {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
data: ConditionNodeData;
|
data: ConditionNodeData;
|
||||||
|
|
@ -38,8 +55,194 @@ const OPERATORS = [
|
||||||
{ value: "NOT_IN", label: "NOT IN" },
|
{ value: "NOT_IN", label: "NOT IN" },
|
||||||
{ value: "IS_NULL", label: "NULL" },
|
{ value: "IS_NULL", label: "NULL" },
|
||||||
{ value: "IS_NOT_NULL", label: "NOT NULL" },
|
{ value: "IS_NOT_NULL", label: "NOT NULL" },
|
||||||
|
{ value: "EXISTS_IN", label: "다른 테이블에 존재함" },
|
||||||
|
{ value: "NOT_EXISTS_IN", label: "다른 테이블에 존재하지 않음" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
// EXISTS 계열 연산자인지 확인
|
||||||
|
const isExistsOperator = (operator: string): boolean => {
|
||||||
|
return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테이블 선택용 검색 가능한 Combobox
|
||||||
|
function TableCombobox({
|
||||||
|
tables,
|
||||||
|
value,
|
||||||
|
onSelect,
|
||||||
|
placeholder = "테이블 검색...",
|
||||||
|
}: {
|
||||||
|
tables: TableInfo[];
|
||||||
|
value: string;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const selectedTable = tables.find((t) => t.tableName === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="mt-1 h-8 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{selectedTable ? (
|
||||||
|
<span className="truncate">
|
||||||
|
{selectedTable.tableLabel}
|
||||||
|
<span className="ml-1 text-gray-400">({selectedTable.tableName})</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">테이블 선택</span>
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[300px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={placeholder} className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-y-auto">
|
||||||
|
{tables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.tableLabel} ${table.tableName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onSelect(table.tableName);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", value === table.tableName ? "opacity-100" : "opacity-0")} />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{table.tableLabel}</span>
|
||||||
|
<span className="text-[10px] text-gray-500">{table.tableName}</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 선택용 검색 가능한 Combobox
|
||||||
|
function ColumnCombobox({
|
||||||
|
columns,
|
||||||
|
value,
|
||||||
|
onSelect,
|
||||||
|
placeholder = "컬럼 검색...",
|
||||||
|
}: {
|
||||||
|
columns: ColumnInfo[];
|
||||||
|
value: string;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const selectedColumn = columns.find((c) => c.columnName === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="mt-1 h-8 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{selectedColumn ? (
|
||||||
|
<span className="truncate">
|
||||||
|
{selectedColumn.columnLabel}
|
||||||
|
<span className="ml-1 text-gray-400">({selectedColumn.columnName})</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">컬럼 선택</span>
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[300px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={placeholder} className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-y-auto">
|
||||||
|
{columns.map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.columnName}
|
||||||
|
value={`${col.columnLabel} ${col.columnName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onSelect(col.columnName);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", value === col.columnName ? "opacity-100" : "opacity-0")} />
|
||||||
|
<span className="font-medium">{col.columnLabel}</span>
|
||||||
|
<span className="ml-1 text-[10px] text-gray-400">({col.columnName})</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 선택 섹션 (자동 로드 포함)
|
||||||
|
function ColumnSelectSection({
|
||||||
|
lookupTable,
|
||||||
|
lookupField,
|
||||||
|
tableColumnsCache,
|
||||||
|
loadingColumns,
|
||||||
|
loadTableColumns,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
lookupTable: string;
|
||||||
|
lookupField: string;
|
||||||
|
tableColumnsCache: Record<string, ColumnInfo[]>;
|
||||||
|
loadingColumns: Record<string, boolean>;
|
||||||
|
loadTableColumns: (tableName: string) => Promise<ColumnInfo[]>;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
// 캐시에 없고 로딩 중이 아니면 자동으로 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (lookupTable && !tableColumnsCache[lookupTable] && !loadingColumns[lookupTable]) {
|
||||||
|
loadTableColumns(lookupTable);
|
||||||
|
}
|
||||||
|
}, [lookupTable, tableColumnsCache, loadingColumns, loadTableColumns]);
|
||||||
|
|
||||||
|
const isLoading = loadingColumns[lookupTable];
|
||||||
|
const columns = tableColumnsCache[lookupTable];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600">
|
||||||
|
<Search className="mr-1 inline h-3 w-3" />
|
||||||
|
비교할 컬럼
|
||||||
|
</Label>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||||
|
컬럼 목록 로딩 중...
|
||||||
|
</div>
|
||||||
|
) : columns && columns.length > 0 ? (
|
||||||
|
<ColumnCombobox columns={columns} value={lookupField} onSelect={onSelect} placeholder="컬럼 검색..." />
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||||
|
컬럼 목록을 로드할 수 없습니다
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) {
|
export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) {
|
||||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||||
|
|
||||||
|
|
@ -48,6 +251,12 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||||
const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND");
|
const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND");
|
||||||
const [availableFields, setAvailableFields] = useState<FieldDefinition[]>([]);
|
const [availableFields, setAvailableFields] = useState<FieldDefinition[]>([]);
|
||||||
|
|
||||||
|
// EXISTS 연산자용 상태
|
||||||
|
const [allTables, setAllTables] = useState<TableInfo[]>([]);
|
||||||
|
const [tableColumnsCache, setTableColumnsCache] = useState<Record<string, ColumnInfo[]>>({});
|
||||||
|
const [loadingTables, setLoadingTables] = useState(false);
|
||||||
|
const [loadingColumns, setLoadingColumns] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
// 데이터 변경 시 로컬 상태 업데이트
|
// 데이터 변경 시 로컬 상태 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDisplayName(data.displayName || "조건 분기");
|
setDisplayName(data.displayName || "조건 분기");
|
||||||
|
|
@ -55,6 +264,100 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||||
setLogic(data.logic || "AND");
|
setLogic(data.logic || "AND");
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
// 전체 테이블 목록 로드 (EXISTS 연산자용)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAllTables = async () => {
|
||||||
|
// 이미 EXISTS 연산자가 있거나 로드된 적이 있으면 스킵
|
||||||
|
if (allTables.length > 0) return;
|
||||||
|
|
||||||
|
// EXISTS 연산자가 하나라도 있으면 테이블 목록 로드
|
||||||
|
const hasExistsOperator = conditions.some((c) => isExistsOperator(c.operator));
|
||||||
|
if (!hasExistsOperator) return;
|
||||||
|
|
||||||
|
setLoadingTables(true);
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setAllTables(
|
||||||
|
response.data.map((t: any) => ({
|
||||||
|
tableName: t.tableName,
|
||||||
|
tableLabel: t.tableLabel || t.tableName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingTables(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAllTables();
|
||||||
|
}, [conditions, allTables.length]);
|
||||||
|
|
||||||
|
// 테이블 컬럼 로드 함수
|
||||||
|
const loadTableColumns = useCallback(
|
||||||
|
async (tableName: string): Promise<ColumnInfo[]> => {
|
||||||
|
// 캐시에 있으면 반환
|
||||||
|
if (tableColumnsCache[tableName]) {
|
||||||
|
return tableColumnsCache[tableName];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 로딩 중이면 스킵
|
||||||
|
if (loadingColumns[tableName]) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로딩 상태 설정
|
||||||
|
setLoadingColumns((prev) => ({ ...prev, [tableName]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// getColumnList 반환: { success, data: { columns, total, ... } }
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
if (response.success && response.data && response.data.columns) {
|
||||||
|
const columns = response.data.columns.map((c: any) => ({
|
||||||
|
columnName: c.columnName,
|
||||||
|
columnLabel: c.columnLabel || c.columnName,
|
||||||
|
dataType: c.dataType,
|
||||||
|
}));
|
||||||
|
setTableColumnsCache((prev) => ({ ...prev, [tableName]: columns }));
|
||||||
|
console.log(`✅ 테이블 ${tableName} 컬럼 로드 완료:`, columns.length, "개");
|
||||||
|
return columns;
|
||||||
|
} else {
|
||||||
|
console.warn(`⚠️ 테이블 ${tableName} 컬럼 조회 실패:`, response);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ 테이블 ${tableName} 컬럼 로드 실패:`, error);
|
||||||
|
} finally {
|
||||||
|
setLoadingColumns((prev) => ({ ...prev, [tableName]: false }));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
[tableColumnsCache, loadingColumns]
|
||||||
|
);
|
||||||
|
|
||||||
|
// EXISTS 연산자 선택 시 테이블 목록 강제 로드
|
||||||
|
const ensureTablesLoaded = useCallback(async () => {
|
||||||
|
if (allTables.length > 0) return;
|
||||||
|
|
||||||
|
setLoadingTables(true);
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setAllTables(
|
||||||
|
response.data.map((t: any) => ({
|
||||||
|
tableName: t.tableName,
|
||||||
|
tableLabel: t.tableLabel || t.tableName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingTables(false);
|
||||||
|
}
|
||||||
|
}, [allTables.length]);
|
||||||
|
|
||||||
// 🔥 연결된 소스 노드의 필드를 재귀적으로 수집
|
// 🔥 연결된 소스 노드의 필드를 재귀적으로 수집
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getAllSourceFields = (currentNodeId: string, visited: Set<string> = new Set()): FieldDefinition[] => {
|
const getAllSourceFields = (currentNodeId: string, visited: Set<string> = new Set()): FieldDefinition[] => {
|
||||||
|
|
@ -170,15 +473,18 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||||
}, [nodeId, nodes, edges]);
|
}, [nodeId, nodes, edges]);
|
||||||
|
|
||||||
const handleAddCondition = () => {
|
const handleAddCondition = () => {
|
||||||
setConditions([
|
const newCondition = {
|
||||||
...conditions,
|
field: "",
|
||||||
{
|
operator: "EQUALS" as ConditionOperator,
|
||||||
field: "",
|
value: "",
|
||||||
operator: "EQUALS",
|
valueType: "static" as "static" | "field",
|
||||||
value: "",
|
// EXISTS 연산자용 필드는 초기값 없음
|
||||||
valueType: "static", // "static" (고정값) 또는 "field" (필드 참조)
|
lookupTable: undefined,
|
||||||
},
|
lookupTableLabel: undefined,
|
||||||
]);
|
lookupField: undefined,
|
||||||
|
lookupFieldLabel: undefined,
|
||||||
|
};
|
||||||
|
setConditions([...conditions, newCondition]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveCondition = (index: number) => {
|
const handleRemoveCondition = (index: number) => {
|
||||||
|
|
@ -196,9 +502,50 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConditionChange = (index: number, field: string, value: any) => {
|
const handleConditionChange = async (index: number, field: string, value: any) => {
|
||||||
const newConditions = [...conditions];
|
const newConditions = [...conditions];
|
||||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||||
|
|
||||||
|
// EXISTS 연산자로 변경 시 테이블 목록 로드 및 기존 value/valueType 초기화
|
||||||
|
if (field === "operator" && isExistsOperator(value)) {
|
||||||
|
await ensureTablesLoaded();
|
||||||
|
// EXISTS 연산자에서는 value, valueType이 필요 없으므로 초기화
|
||||||
|
newConditions[index].value = "";
|
||||||
|
newConditions[index].valueType = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXISTS 연산자에서 다른 연산자로 변경 시 lookup 필드들 초기화
|
||||||
|
if (field === "operator" && !isExistsOperator(value)) {
|
||||||
|
newConditions[index].lookupTable = undefined;
|
||||||
|
newConditions[index].lookupTableLabel = undefined;
|
||||||
|
newConditions[index].lookupField = undefined;
|
||||||
|
newConditions[index].lookupFieldLabel = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupTable 변경 시 컬럼 목록 로드 및 라벨 설정
|
||||||
|
if (field === "lookupTable" && value) {
|
||||||
|
const tableInfo = allTables.find((t) => t.tableName === value);
|
||||||
|
if (tableInfo) {
|
||||||
|
newConditions[index].lookupTableLabel = tableInfo.tableLabel;
|
||||||
|
}
|
||||||
|
// 테이블 변경 시 필드 초기화
|
||||||
|
newConditions[index].lookupField = undefined;
|
||||||
|
newConditions[index].lookupFieldLabel = undefined;
|
||||||
|
// 컬럼 목록 미리 로드
|
||||||
|
await loadTableColumns(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupField 변경 시 라벨 설정
|
||||||
|
if (field === "lookupField" && value) {
|
||||||
|
const tableName = newConditions[index].lookupTable;
|
||||||
|
if (tableName && tableColumnsCache[tableName]) {
|
||||||
|
const columnInfo = tableColumnsCache[tableName].find((c) => c.columnName === value);
|
||||||
|
if (columnInfo) {
|
||||||
|
newConditions[index].lookupFieldLabel = columnInfo.columnLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setConditions(newConditions);
|
setConditions(newConditions);
|
||||||
updateNode(nodeId, {
|
updateNode(nodeId, {
|
||||||
conditions: newConditions,
|
conditions: newConditions,
|
||||||
|
|
@ -329,64 +676,114 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{condition.operator !== "IS_NULL" && condition.operator !== "IS_NOT_NULL" && (
|
{/* EXISTS 연산자인 경우: 테이블/필드 선택 UI (검색 가능한 Combobox) */}
|
||||||
|
{isExistsOperator(condition.operator) && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-gray-600">비교 값 타입</Label>
|
<Label className="text-xs text-gray-600">
|
||||||
<Select
|
<Database className="mr-1 inline h-3 w-3" />
|
||||||
value={(condition as any).valueType || "static"}
|
조회할 테이블
|
||||||
onValueChange={(value) => handleConditionChange(index, "valueType", value)}
|
</Label>
|
||||||
>
|
{loadingTables ? (
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||||
<SelectValue />
|
테이블 목록 로딩 중...
|
||||||
</SelectTrigger>
|
</div>
|
||||||
<SelectContent>
|
) : allTables.length > 0 ? (
|
||||||
<SelectItem value="static">고정값</SelectItem>
|
<TableCombobox
|
||||||
<SelectItem value="field">필드 참조</SelectItem>
|
tables={allTables}
|
||||||
</SelectContent>
|
value={(condition as any).lookupTable || ""}
|
||||||
</Select>
|
onSelect={(value) => handleConditionChange(index, "lookupTable", value)}
|
||||||
|
placeholder="테이블 검색..."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||||
|
테이블 목록을 로드할 수 없습니다
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{(condition as any).lookupTable && (
|
||||||
<Label className="text-xs text-gray-600">
|
<ColumnSelectSection
|
||||||
{(condition as any).valueType === "field" ? "비교 필드" : "비교 값"}
|
lookupTable={(condition as any).lookupTable}
|
||||||
</Label>
|
lookupField={(condition as any).lookupField || ""}
|
||||||
{(condition as any).valueType === "field" ? (
|
tableColumnsCache={tableColumnsCache}
|
||||||
// 필드 참조: 드롭다운으로 선택
|
loadingColumns={loadingColumns}
|
||||||
availableFields.length > 0 ? (
|
loadTableColumns={loadTableColumns}
|
||||||
<Select
|
onSelect={(value) => handleConditionChange(index, "lookupField", value)}
|
||||||
value={condition.value as string}
|
/>
|
||||||
onValueChange={(value) => handleConditionChange(index, "value", value)}
|
)}
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
<div className="rounded bg-purple-50 p-2 text-xs text-purple-700">
|
||||||
<SelectValue placeholder="비교할 필드 선택" />
|
{condition.operator === "EXISTS_IN"
|
||||||
</SelectTrigger>
|
? `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하면 TRUE`
|
||||||
<SelectContent>
|
: `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하지 않으면 TRUE`}
|
||||||
{availableFields.map((field) => (
|
|
||||||
<SelectItem key={field.name} value={field.name}>
|
|
||||||
{field.label || field.name}
|
|
||||||
{field.type && <span className="ml-2 text-xs text-gray-400">({field.type})</span>}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
|
||||||
소스 노드를 연결하세요
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
// 고정값: 직접 입력
|
|
||||||
<Input
|
|
||||||
value={condition.value as string}
|
|
||||||
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
|
|
||||||
placeholder="비교할 값"
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 일반 연산자인 경우: 기존 비교값 UI */}
|
||||||
|
{condition.operator !== "IS_NULL" &&
|
||||||
|
condition.operator !== "IS_NOT_NULL" &&
|
||||||
|
!isExistsOperator(condition.operator) && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600">비교 값 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={(condition as any).valueType || "static"}
|
||||||
|
onValueChange={(value) => handleConditionChange(index, "valueType", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="static">고정값</SelectItem>
|
||||||
|
<SelectItem value="field">필드 참조</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600">
|
||||||
|
{(condition as any).valueType === "field" ? "비교 필드" : "비교 값"}
|
||||||
|
</Label>
|
||||||
|
{(condition as any).valueType === "field" ? (
|
||||||
|
// 필드 참조: 드롭다운으로 선택
|
||||||
|
availableFields.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
value={condition.value as string}
|
||||||
|
onValueChange={(value) => handleConditionChange(index, "value", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||||
|
<SelectValue placeholder="비교할 필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableFields.map((field) => (
|
||||||
|
<SelectItem key={field.name} value={field.name}>
|
||||||
|
{field.label || field.name}
|
||||||
|
{field.type && (
|
||||||
|
<span className="ml-2 text-xs text-gray-400">({field.type})</span>
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||||
|
소스 노드를 연결하세요
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
// 고정값: 직접 입력
|
||||||
|
<Input
|
||||||
|
value={condition.value as string}
|
||||||
|
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
|
||||||
|
placeholder="비교할 값"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -402,20 +799,28 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||||
{/* 안내 */}
|
{/* 안내 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="rounded bg-blue-50 p-3 text-xs text-blue-700">
|
<div className="rounded bg-blue-50 p-3 text-xs text-blue-700">
|
||||||
🔌 <strong>소스 노드 연결</strong>: 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다.
|
<strong>소스 노드 연결</strong>: 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다.
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
||||||
🔄 <strong>비교 값 타입</strong>:<br />• <strong>고정값</strong>: 직접 입력한 값과 비교 (예: age > 30)
|
<strong>비교 값 타입</strong>:<br />
|
||||||
<br />• <strong>필드 참조</strong>: 다른 필드의 값과 비교 (예: 주문수량 > 재고수량)
|
- <strong>고정값</strong>: 직접 입력한 값과 비교 (예: age > 30)
|
||||||
|
<br />- <strong>필드 참조</strong>: 다른 필드의 값과 비교 (예: 주문수량 > 재고수량)
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
|
||||||
|
<strong>테이블 존재 여부 검사</strong>:<br />
|
||||||
|
- <strong>다른 테이블에 존재함</strong>: 값이 다른 테이블에 있으면 TRUE
|
||||||
|
<br />- <strong>다른 테이블에 존재하지 않음</strong>: 값이 다른 테이블에 없으면 TRUE
|
||||||
|
<br />
|
||||||
|
(예: 품명이 품목정보 테이블에 없으면 자동 등록)
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||||
💡 <strong>AND</strong>: 모든 조건이 참이어야 TRUE 출력
|
<strong>AND</strong>: 모든 조건이 참이어야 TRUE 출력
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||||
💡 <strong>OR</strong>: 하나라도 참이면 TRUE 출력
|
<strong>OR</strong>: 하나라도 참이면 TRUE 출력
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||||
⚡ TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다.
|
TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,14 @@ interface EmbeddedScreenProps {
|
||||||
onSelectionChanged?: (selectedRows: any[]) => void;
|
onSelectionChanged?: (selectedRows: any[]) => void;
|
||||||
position?: SplitPanelPosition; // 분할 패널 내 위치 (left/right)
|
position?: SplitPanelPosition; // 분할 패널 내 위치 (left/right)
|
||||||
initialFormData?: Record<string, any>; // 🆕 수정 모드에서 전달되는 초기 데이터
|
initialFormData?: Record<string, any>; // 🆕 수정 모드에서 전달되는 초기 데이터
|
||||||
|
groupedData?: Record<string, any>[]; // 🆕 그룹 데이터 (수정 모드에서 원본 데이터 추적용)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 임베드된 화면 컴포넌트
|
* 임베드된 화면 컴포넌트
|
||||||
*/
|
*/
|
||||||
export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenProps>(
|
export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenProps>(
|
||||||
({ embedding, onSelectionChanged, position, initialFormData }, ref) => {
|
({ embedding, onSelectionChanged, position, initialFormData, groupedData }, ref) => {
|
||||||
const [layout, setLayout] = useState<ComponentData[]>([]);
|
const [layout, setLayout] = useState<ComponentData[]>([]);
|
||||||
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -430,6 +431,8 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
||||||
userId={userId}
|
userId={userId}
|
||||||
userName={userName}
|
userName={userName}
|
||||||
companyCode={companyCode}
|
companyCode={companyCode}
|
||||||
|
groupedData={groupedData}
|
||||||
|
initialData={initialFormData}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,14 @@ interface ScreenSplitPanelProps {
|
||||||
screenId?: number;
|
screenId?: number;
|
||||||
config?: any; // 설정 패널에서 오는 config (leftScreenId, rightScreenId, splitRatio, resizable)
|
config?: any; // 설정 패널에서 오는 config (leftScreenId, rightScreenId, splitRatio, resizable)
|
||||||
initialFormData?: Record<string, any>; // 🆕 수정 모드에서 전달되는 초기 데이터
|
initialFormData?: Record<string, any>; // 🆕 수정 모드에서 전달되는 초기 데이터
|
||||||
|
groupedData?: Record<string, any>[]; // 🆕 그룹 데이터 (수정 모드에서 원본 데이터 추적용)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 분할 패널 컴포넌트
|
* 분할 패널 컴포넌트
|
||||||
* 순수하게 화면 분할 기능만 제공합니다.
|
* 순수하게 화면 분할 기능만 제공합니다.
|
||||||
*/
|
*/
|
||||||
export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSplitPanelProps) {
|
export function ScreenSplitPanel({ screenId, config, initialFormData, groupedData }: ScreenSplitPanelProps) {
|
||||||
// config에서 splitRatio 추출 (기본값 50)
|
// config에서 splitRatio 추출 (기본값 50)
|
||||||
const configSplitRatio = config?.splitRatio ?? 50;
|
const configSplitRatio = config?.splitRatio ?? 50;
|
||||||
|
|
||||||
|
|
@ -117,7 +118,7 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp
|
||||||
{/* 좌측 패널 */}
|
{/* 좌측 패널 */}
|
||||||
<div style={{ width: `${splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden border-r">
|
<div style={{ width: `${splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden border-r">
|
||||||
{hasLeftScreen ? (
|
{hasLeftScreen ? (
|
||||||
<EmbeddedScreen embedding={leftEmbedding!} position="left" initialFormData={initialFormData} />
|
<EmbeddedScreen embedding={leftEmbedding!} position="left" initialFormData={initialFormData} groupedData={groupedData} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center bg-muted/30">
|
<div className="flex h-full items-center justify-center bg-muted/30">
|
||||||
<p className="text-muted-foreground text-sm">좌측 화면을 선택하세요</p>
|
<p className="text-muted-foreground text-sm">좌측 화면을 선택하세요</p>
|
||||||
|
|
@ -157,7 +158,7 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp
|
||||||
{/* 우측 패널 */}
|
{/* 우측 패널 */}
|
||||||
<div style={{ width: `${100 - splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden">
|
<div style={{ width: `${100 - splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden">
|
||||||
{hasRightScreen ? (
|
{hasRightScreen ? (
|
||||||
<EmbeddedScreen embedding={rightEmbedding!} position="right" initialFormData={initialFormData} />
|
<EmbeddedScreen embedding={rightEmbedding!} position="right" initialFormData={initialFormData} groupedData={groupedData} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center bg-muted/30">
|
<div className="flex h-full items-center justify-center bg-muted/30">
|
||||||
<p className="text-muted-foreground text-sm">우측 화면을 선택하세요</p>
|
<p className="text-muted-foreground text-sm">우측 화면을 선택하세요</p>
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
editModalTitle: String(config.action?.editModalTitle || ""),
|
editModalTitle: String(config.action?.editModalTitle || ""),
|
||||||
editModalDescription: String(config.action?.editModalDescription || ""),
|
editModalDescription: String(config.action?.editModalDescription || ""),
|
||||||
targetUrl: String(config.action?.targetUrl || ""),
|
targetUrl: String(config.action?.targetUrl || ""),
|
||||||
|
groupByColumn: String(config.action?.groupByColumns?.[0] || ""),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [screens, setScreens] = useState<ScreenOption[]>([]);
|
const [screens, setScreens] = useState<ScreenOption[]>([]);
|
||||||
|
|
@ -97,6 +98,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
const [modalTargetColumns, setModalTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
|
const [modalTargetColumns, setModalTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||||
const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState<Record<number, boolean>>({});
|
const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState<Record<number, boolean>>({});
|
||||||
const [modalTargetPopoverOpen, setModalTargetPopoverOpen] = useState<Record<number, boolean>>({});
|
const [modalTargetPopoverOpen, setModalTargetPopoverOpen] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
// 🆕 그룹화 컬럼 선택용 상태
|
||||||
|
const [currentTableColumns, setCurrentTableColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||||
|
const [groupByColumnOpen, setGroupByColumnOpen] = useState(false);
|
||||||
|
const [groupByColumnSearch, setGroupByColumnSearch] = useState("");
|
||||||
const [modalSourceSearch, setModalSourceSearch] = useState<Record<number, string>>({});
|
const [modalSourceSearch, setModalSourceSearch] = useState<Record<number, string>>({});
|
||||||
const [modalTargetSearch, setModalTargetSearch] = useState<Record<number, string>>({});
|
const [modalTargetSearch, setModalTargetSearch] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
|
|
@ -130,6 +136,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
editModalTitle: String(latestAction.editModalTitle || ""),
|
editModalTitle: String(latestAction.editModalTitle || ""),
|
||||||
editModalDescription: String(latestAction.editModalDescription || ""),
|
editModalDescription: String(latestAction.editModalDescription || ""),
|
||||||
targetUrl: String(latestAction.targetUrl || ""),
|
targetUrl: String(latestAction.targetUrl || ""),
|
||||||
|
groupByColumn: String(latestAction.groupByColumns?.[0] || ""),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🆕 제목 블록 초기화
|
// 🆕 제목 블록 초기화
|
||||||
|
|
@ -327,6 +334,35 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
loadColumns();
|
loadColumns();
|
||||||
}, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]);
|
}, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]);
|
||||||
|
|
||||||
|
// 🆕 현재 테이블 컬럼 로드 (그룹화 컬럼 선택용)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentTableName) return;
|
||||||
|
|
||||||
|
const loadCurrentTableColumns = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/table-management/tables/${currentTableName}/columns`);
|
||||||
|
if (response.data.success) {
|
||||||
|
let columnData = response.data.data;
|
||||||
|
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||||
|
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||||
|
|
||||||
|
if (Array.isArray(columnData)) {
|
||||||
|
const columns = columnData.map((col: any) => ({
|
||||||
|
name: col.name || col.columnName,
|
||||||
|
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||||
|
}));
|
||||||
|
setCurrentTableColumns(columns);
|
||||||
|
console.log(`✅ 현재 테이블 ${currentTableName} 컬럼 로드 성공:`, columns.length, "개");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("현재 테이블 컬럼 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCurrentTableColumns();
|
||||||
|
}, [currentTableName]);
|
||||||
|
|
||||||
// 🆕 openModalWithData 소스/타겟 테이블 컬럼 로드
|
// 🆕 openModalWithData 소스/타겟 테이블 컬럼 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const actionType = config.action?.type;
|
const actionType = config.action?.type;
|
||||||
|
|
@ -1529,6 +1565,106 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-group-by-column">그룹화 컬럼</Label>
|
||||||
|
<Popover open={groupByColumnOpen} onOpenChange={setGroupByColumnOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={groupByColumnOpen}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{localInputs.groupByColumn ? (
|
||||||
|
<span>
|
||||||
|
{localInputs.groupByColumn}
|
||||||
|
{currentTableColumns.find((col) => col.name === localInputs.groupByColumn)?.label &&
|
||||||
|
currentTableColumns.find((col) => col.name === localInputs.groupByColumn)?.label !== localInputs.groupByColumn && (
|
||||||
|
<span className="ml-1 text-muted-foreground">
|
||||||
|
({currentTableColumns.find((col) => col.name === localInputs.groupByColumn)?.label})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">컬럼을 선택하세요</span>
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center border-b px-3 py-2">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<Input
|
||||||
|
placeholder="컬럼명 또는 라벨 검색..."
|
||||||
|
value={groupByColumnSearch}
|
||||||
|
onChange={(e) => setGroupByColumnSearch(e.target.value)}
|
||||||
|
className="border-0 p-0 focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[200px] overflow-auto">
|
||||||
|
{currentTableColumns.length === 0 ? (
|
||||||
|
<div className="p-3 text-sm text-muted-foreground">
|
||||||
|
{currentTableName ? "컬럼을 불러오는 중..." : "테이블이 설정되지 않았습니다"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 선택 해제 옵션 */}
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer items-center px-3 py-2 hover:bg-muted"
|
||||||
|
onClick={() => {
|
||||||
|
setLocalInputs((prev) => ({ ...prev, groupByColumn: "" }));
|
||||||
|
onUpdateProperty("componentConfig.action.groupByColumns", undefined);
|
||||||
|
setGroupByColumnOpen(false);
|
||||||
|
setGroupByColumnSearch("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-4 w-4", !localInputs.groupByColumn ? "opacity-100" : "opacity-0")} />
|
||||||
|
<span className="text-muted-foreground">선택 안 함</span>
|
||||||
|
</div>
|
||||||
|
{/* 컬럼 목록 */}
|
||||||
|
{currentTableColumns
|
||||||
|
.filter((col) => {
|
||||||
|
if (!groupByColumnSearch) return true;
|
||||||
|
const search = groupByColumnSearch.toLowerCase();
|
||||||
|
return (
|
||||||
|
col.name.toLowerCase().includes(search) ||
|
||||||
|
col.label.toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((col) => (
|
||||||
|
<div
|
||||||
|
key={col.name}
|
||||||
|
className="flex cursor-pointer items-center px-3 py-2 hover:bg-muted"
|
||||||
|
onClick={() => {
|
||||||
|
setLocalInputs((prev) => ({ ...prev, groupByColumn: col.name }));
|
||||||
|
onUpdateProperty("componentConfig.action.groupByColumns", [col.name]);
|
||||||
|
setGroupByColumnOpen(false);
|
||||||
|
setGroupByColumnSearch("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn("mr-2 h-4 w-4", localInputs.groupByColumn === col.name ? "opacity-100" : "opacity-0")}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{col.name}</span>
|
||||||
|
{col.label !== col.name && (
|
||||||
|
<span className="text-xs text-muted-foreground">{col.label}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
여러 행을 하나의 그룹으로 묶어서 수정할 때 사용합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer {
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { component, style = {}, componentConfig, config, screenId, formData } = this.props as any;
|
const { component, style = {}, componentConfig, config, screenId, formData, groupedData } = this.props as any;
|
||||||
|
|
||||||
// componentConfig 또는 config 또는 component.componentConfig 사용
|
// componentConfig 또는 config 또는 component.componentConfig 사용
|
||||||
const finalConfig = componentConfig || config || component?.componentConfig || {};
|
const finalConfig = componentConfig || config || component?.componentConfig || {};
|
||||||
|
|
@ -77,6 +77,7 @@ class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer {
|
||||||
screenId={screenId || finalConfig.screenId}
|
screenId={screenId || finalConfig.screenId}
|
||||||
config={finalConfig}
|
config={finalConfig}
|
||||||
initialFormData={formData} // 🆕 수정 데이터 전달
|
initialFormData={formData} // 🆕 수정 데이터 전달
|
||||||
|
groupedData={groupedData} // 🆕 그룹 데이터 전달 (수정 모드에서 원본 데이터 추적용)
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -304,6 +304,9 @@ export interface ButtonActionContext {
|
||||||
selectedLeftData?: Record<string, any>;
|
selectedLeftData?: Record<string, any>;
|
||||||
refreshRightPanel?: () => void;
|
refreshRightPanel?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 저장된 데이터 (저장 후 제어 실행 시 플로우에 전달)
|
||||||
|
savedData?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1036,10 +1039,11 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 공통 필드 병합 + 사용자 정보 추가
|
// 🆕 공통 필드 병합 + 사용자 정보 추가
|
||||||
// 공통 필드를 먼저 넣고, 개별 항목 데이터로 덮어씀 (개별 항목이 우선)
|
// 개별 항목 데이터를 먼저 넣고, 공통 필드로 덮어씀 (공통 필드가 우선)
|
||||||
|
// 이유: 사용자가 공통 필드(출고상태 등)를 변경하면 모든 항목에 적용되어야 함
|
||||||
const dataWithMeta: Record<string, unknown> = {
|
const dataWithMeta: Record<string, unknown> = {
|
||||||
...commonFields, // 범용 폼 모달의 공통 필드 (order_no, manager_id 등)
|
|
||||||
...dataToSave, // RepeaterFieldGroup의 개별 항목 데이터
|
...dataToSave, // RepeaterFieldGroup의 개별 항목 데이터
|
||||||
|
...commonFields, // 범용 폼 모달의 공통 필드 (outbound_status 등) - 공통 필드가 우선!
|
||||||
created_by: context.userId,
|
created_by: context.userId,
|
||||||
updated_by: context.userId,
|
updated_by: context.userId,
|
||||||
company_code: context.companyCode,
|
company_code: context.companyCode,
|
||||||
|
|
@ -1251,7 +1255,49 @@ export class ButtonActionExecutor {
|
||||||
// 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우)
|
// 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우)
|
||||||
if (config.enableDataflowControl && config.dataflowConfig) {
|
if (config.enableDataflowControl && config.dataflowConfig) {
|
||||||
console.log("🎯 저장 후 제어 실행 시작:", config.dataflowConfig);
|
console.log("🎯 저장 후 제어 실행 시작:", config.dataflowConfig);
|
||||||
await this.executeAfterSaveControl(config, context);
|
|
||||||
|
// 테이블 섹션 데이터 파싱 (comp_로 시작하는 필드에 JSON 배열이 있는 경우)
|
||||||
|
// 입고 화면 등에서 품목 목록이 comp_xxx 필드에 JSON 문자열로 저장됨
|
||||||
|
const formData: Record<string, any> = (saveResult.data || context.formData || {}) as Record<string, any>;
|
||||||
|
let parsedSectionData: any[] = [];
|
||||||
|
|
||||||
|
// comp_로 시작하는 필드에서 테이블 섹션 데이터 찾기
|
||||||
|
const compFieldKey = Object.keys(formData).find(key =>
|
||||||
|
key.startsWith("comp_") && typeof formData[key] === "string"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (compFieldKey) {
|
||||||
|
try {
|
||||||
|
const sectionData = JSON.parse(formData[compFieldKey]);
|
||||||
|
if (Array.isArray(sectionData) && sectionData.length > 0) {
|
||||||
|
// 공통 필드와 섹션 데이터 병합
|
||||||
|
parsedSectionData = sectionData.map((item: any) => {
|
||||||
|
// 섹션 데이터에서 불필요한 내부 필드 제거
|
||||||
|
const { _isNewItem, _targetTable, _existingRecord, ...cleanItem } = item;
|
||||||
|
// 공통 필드(comp_ 필드 제외) + 섹션 아이템 병합
|
||||||
|
const commonFields: Record<string, any> = {};
|
||||||
|
Object.keys(formData).forEach(key => {
|
||||||
|
if (!key.startsWith("comp_") && !key.endsWith("_numberingRuleId")) {
|
||||||
|
commonFields[key] = formData[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { ...commonFields, ...cleanItem };
|
||||||
|
});
|
||||||
|
console.log(`📦 [handleSave] 테이블 섹션 데이터 파싱 완료: ${parsedSectionData.length}건`, parsedSectionData[0]);
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.warn("⚠️ [handleSave] 테이블 섹션 데이터 파싱 실패:", parseError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 저장된 데이터를 context에 추가하여 플로우에 전달
|
||||||
|
const contextWithSavedData = {
|
||||||
|
...context,
|
||||||
|
savedData: formData,
|
||||||
|
// 파싱된 섹션 데이터가 있으면 selectedRowsData로 전달
|
||||||
|
selectedRowsData: parsedSectionData.length > 0 ? parsedSectionData : context.selectedRowsData,
|
||||||
|
};
|
||||||
|
await this.executeAfterSaveControl(config, contextWithSavedData);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
|
throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
|
||||||
|
|
@ -3643,8 +3689,20 @@ export class ButtonActionExecutor {
|
||||||
// 노드 플로우 실행 API
|
// 노드 플로우 실행 API
|
||||||
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
||||||
|
|
||||||
// 데이터 소스 준비
|
// 데이터 소스 준비: context-data 모드는 배열을 기대함
|
||||||
const sourceData: any = context.formData || {};
|
// 우선순위: selectedRowsData > savedData > formData
|
||||||
|
// - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함)
|
||||||
|
// - savedData: 저장 API 응답 데이터
|
||||||
|
// - formData: 폼에 입력된 데이터
|
||||||
|
let sourceData: any[];
|
||||||
|
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||||
|
sourceData = context.selectedRowsData;
|
||||||
|
console.log("📦 [다중제어] selectedRowsData 사용:", sourceData.length, "건");
|
||||||
|
} else {
|
||||||
|
const savedData = context.savedData || context.formData || {};
|
||||||
|
sourceData = Array.isArray(savedData) ? savedData : [savedData];
|
||||||
|
console.log("📦 [다중제어] savedData/formData 사용:", sourceData.length, "건");
|
||||||
|
}
|
||||||
|
|
||||||
let allSuccess = true;
|
let allSuccess = true;
|
||||||
const results: Array<{ flowId: number; flowName: string; success: boolean; message?: string }> = [];
|
const results: Array<{ flowId: number; flowName: string; success: boolean; message?: string }> = [];
|
||||||
|
|
@ -3751,8 +3809,20 @@ export class ButtonActionExecutor {
|
||||||
// 노드 플로우 실행 API 호출
|
// 노드 플로우 실행 API 호출
|
||||||
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
||||||
|
|
||||||
// 데이터 소스 준비
|
// 데이터 소스 준비: context-data 모드는 배열을 기대함
|
||||||
const sourceData: any = context.formData || {};
|
// 우선순위: selectedRowsData > savedData > formData
|
||||||
|
// - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함)
|
||||||
|
// - savedData: 저장 API 응답 데이터
|
||||||
|
// - formData: 폼에 입력된 데이터
|
||||||
|
let sourceData: any[];
|
||||||
|
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||||
|
sourceData = context.selectedRowsData;
|
||||||
|
console.log("📦 [단일제어] selectedRowsData 사용:", sourceData.length, "건");
|
||||||
|
} else {
|
||||||
|
const savedData = context.savedData || context.formData || {};
|
||||||
|
sourceData = Array.isArray(savedData) ? savedData : [savedData];
|
||||||
|
console.log("📦 [단일제어] savedData/formData 사용:", sourceData.length, "건");
|
||||||
|
}
|
||||||
|
|
||||||
// repeat-screen-modal 데이터가 있으면 병합
|
// repeat-screen-modal 데이터가 있으면 병합
|
||||||
const repeatScreenModalKeys = Object.keys(context.formData || {}).filter((key) =>
|
const repeatScreenModalKeys = Object.keys(context.formData || {}).filter((key) =>
|
||||||
|
|
@ -3765,7 +3835,8 @@ export class ButtonActionExecutor {
|
||||||
console.log("📦 노드 플로우에 전달할 데이터:", {
|
console.log("📦 노드 플로우에 전달할 데이터:", {
|
||||||
flowId,
|
flowId,
|
||||||
dataSourceType: controlDataSource,
|
dataSourceType: controlDataSource,
|
||||||
sourceData,
|
sourceDataCount: sourceData.length,
|
||||||
|
sourceDataSample: sourceData[0],
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await executeNodeFlow(flowId, {
|
const result = await executeNodeFlow(flowId, {
|
||||||
|
|
|
||||||
|
|
@ -95,24 +95,35 @@ export interface RestAPISourceNodeData {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 조건 연산자 타입
|
||||||
|
export type ConditionOperator =
|
||||||
|
| "EQUALS"
|
||||||
|
| "NOT_EQUALS"
|
||||||
|
| "GREATER_THAN"
|
||||||
|
| "LESS_THAN"
|
||||||
|
| "GREATER_THAN_OR_EQUAL"
|
||||||
|
| "LESS_THAN_OR_EQUAL"
|
||||||
|
| "LIKE"
|
||||||
|
| "NOT_LIKE"
|
||||||
|
| "IN"
|
||||||
|
| "NOT_IN"
|
||||||
|
| "IS_NULL"
|
||||||
|
| "IS_NOT_NULL"
|
||||||
|
| "EXISTS_IN" // 다른 테이블에 존재함
|
||||||
|
| "NOT_EXISTS_IN"; // 다른 테이블에 존재하지 않음
|
||||||
|
|
||||||
// 조건 분기 노드
|
// 조건 분기 노드
|
||||||
export interface ConditionNodeData {
|
export interface ConditionNodeData {
|
||||||
conditions: Array<{
|
conditions: Array<{
|
||||||
field: string;
|
field: string;
|
||||||
operator:
|
operator: ConditionOperator;
|
||||||
| "EQUALS"
|
|
||||||
| "NOT_EQUALS"
|
|
||||||
| "GREATER_THAN"
|
|
||||||
| "LESS_THAN"
|
|
||||||
| "GREATER_THAN_OR_EQUAL"
|
|
||||||
| "LESS_THAN_OR_EQUAL"
|
|
||||||
| "LIKE"
|
|
||||||
| "NOT_LIKE"
|
|
||||||
| "IN"
|
|
||||||
| "NOT_IN"
|
|
||||||
| "IS_NULL"
|
|
||||||
| "IS_NOT_NULL";
|
|
||||||
value: any;
|
value: any;
|
||||||
|
valueType?: "static" | "field"; // 비교 값 타입
|
||||||
|
// EXISTS_IN / NOT_EXISTS_IN 전용 필드
|
||||||
|
lookupTable?: string; // 조회할 테이블명
|
||||||
|
lookupTableLabel?: string; // 조회할 테이블 라벨
|
||||||
|
lookupField?: string; // 조회할 테이블의 비교 필드
|
||||||
|
lookupFieldLabel?: string; // 조회할 테이블의 비교 필드 라벨
|
||||||
}>;
|
}>;
|
||||||
logic: "AND" | "OR";
|
logic: "AND" | "OR";
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue