조건 그룹핑 구현
This commit is contained in:
parent
dbad9bbc0c
commit
41f40ac216
|
|
@ -5,19 +5,21 @@ const prisma = new PrismaClient();
|
|||
|
||||
// 조건 노드 타입 정의
|
||||
interface ConditionNode {
|
||||
type: "group" | "condition";
|
||||
operator?: "AND" | "OR";
|
||||
children?: ConditionNode[];
|
||||
id: string; // 고유 ID
|
||||
type: "condition" | "group-start" | "group-end";
|
||||
field?: string;
|
||||
operator_type?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value?: any;
|
||||
dataType?: string;
|
||||
logicalOperator?: "AND" | "OR"; // 다음 조건과의 논리 연산자
|
||||
groupId?: string; // 그룹 ID (group-start와 group-end가 같은 groupId를 가짐)
|
||||
groupLevel?: number; // 중첩 레벨 (0, 1, 2, ...)
|
||||
}
|
||||
|
||||
// 조건 제어 정보
|
||||
interface ConditionControl {
|
||||
triggerType: "insert" | "update" | "delete" | "insert_update";
|
||||
conditionTree: ConditionNode | null;
|
||||
conditionTree: ConditionNode | ConditionNode[] | null;
|
||||
}
|
||||
|
||||
// 연결 카테고리 정보
|
||||
|
|
@ -237,34 +239,105 @@ export class EventTriggerService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 조건 평가
|
||||
* 조건 평가 (플랫 구조 + 그룹핑 지원)
|
||||
*/
|
||||
private static async evaluateCondition(
|
||||
condition: ConditionNode,
|
||||
condition: ConditionNode | ConditionNode[],
|
||||
data: Record<string, any>
|
||||
): Promise<boolean> {
|
||||
if (condition.type === "group") {
|
||||
if (!condition.children || condition.children.length === 0) {
|
||||
// 단일 조건인 경우 (하위 호환성)
|
||||
if (!Array.isArray(condition)) {
|
||||
if (condition.type === "condition") {
|
||||
return this.evaluateSingleCondition(condition, data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
condition.children.map((child) => this.evaluateCondition(child, data))
|
||||
);
|
||||
|
||||
if (condition.operator === "OR") {
|
||||
return results.some((result) => result);
|
||||
} else {
|
||||
// AND
|
||||
return results.every((result) => result);
|
||||
// 조건 배열인 경우 (새로운 그룹핑 시스템)
|
||||
return this.evaluateConditionList(condition, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건 리스트 평가 (괄호 그룹핑 지원)
|
||||
*/
|
||||
private static async evaluateConditionList(
|
||||
conditions: ConditionNode[],
|
||||
data: Record<string, any>
|
||||
): Promise<boolean> {
|
||||
if (conditions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 조건을 평가 가능한 표현식으로 변환
|
||||
const expression = await this.buildConditionExpression(conditions, data);
|
||||
|
||||
// 표현식 평가
|
||||
return this.evaluateExpression(expression);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건들을 평가 가능한 표현식으로 변환
|
||||
*/
|
||||
private static async buildConditionExpression(
|
||||
conditions: ConditionNode[],
|
||||
data: Record<string, any>
|
||||
): Promise<string> {
|
||||
const tokens: string[] = [];
|
||||
|
||||
for (let i = 0; i < conditions.length; i++) {
|
||||
const condition = conditions[i];
|
||||
|
||||
if (condition.type === "group-start") {
|
||||
// 이전 조건과의 논리 연산자 추가
|
||||
if (i > 0 && condition.logicalOperator) {
|
||||
tokens.push(condition.logicalOperator);
|
||||
}
|
||||
tokens.push("(");
|
||||
} else if (condition.type === "group-end") {
|
||||
tokens.push(")");
|
||||
} else if (condition.type === "condition") {
|
||||
return this.evaluateSingleCondition(condition, data);
|
||||
// 이전 조건과의 논리 연산자 추가
|
||||
if (i > 0 && condition.logicalOperator) {
|
||||
tokens.push(condition.logicalOperator);
|
||||
}
|
||||
|
||||
// 조건 평가 결과를 토큰으로 추가
|
||||
const result = await this.evaluateSingleCondition(condition, data);
|
||||
tokens.push(result.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return tokens.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* 논리 표현식 평가 (괄호 우선순위 지원)
|
||||
*/
|
||||
private static evaluateExpression(expression: string): boolean {
|
||||
try {
|
||||
// 안전한 논리 표현식 평가
|
||||
// true/false와 AND/OR/괄호만 포함된 표현식을 평가
|
||||
const sanitizedExpression = expression
|
||||
.replace(/\bAND\b/g, "&&")
|
||||
.replace(/\bOR\b/g, "||")
|
||||
.replace(/\btrue\b/g, "true")
|
||||
.replace(/\bfalse\b/g, "false");
|
||||
|
||||
// 보안을 위해 허용된 문자만 확인
|
||||
if (!/^[true|false|\s|&|\||\(|\)]+$/.test(sanitizedExpression)) {
|
||||
logger.warn(`Invalid expression: ${expression}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Function constructor를 사용한 안전한 평가
|
||||
const result = new Function(`return ${sanitizedExpression}`)();
|
||||
return Boolean(result);
|
||||
} catch (error) {
|
||||
logger.error(`Error evaluating expression: ${expression}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션별 조건들 평가 (AND/OR 연산자 지원)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -62,12 +62,7 @@ interface DataSaveSettings {
|
|||
id: string;
|
||||
name: string;
|
||||
actionType: "insert" | "update" | "delete" | "upsert";
|
||||
conditions?: Array<{
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value: string;
|
||||
logicalOperator?: "AND" | "OR";
|
||||
}>;
|
||||
conditions?: ConditionNode[];
|
||||
fieldMappings: Array<{
|
||||
sourceTable?: string;
|
||||
sourceField: string;
|
||||
|
|
@ -353,14 +348,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
? {
|
||||
control: {
|
||||
triggerType: "insert",
|
||||
conditionTree:
|
||||
conditions.length > 0
|
||||
? {
|
||||
type: "group" as const,
|
||||
operator: "AND" as const,
|
||||
children: conditions,
|
||||
}
|
||||
: null,
|
||||
conditionTree: conditions.length > 0 ? conditions : null,
|
||||
},
|
||||
category: {
|
||||
type: config.connectionType,
|
||||
|
|
@ -446,19 +434,87 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
return config.connectionType === "data-save" || config.connectionType === "external-call";
|
||||
};
|
||||
|
||||
// 고유 ID 생성 헬퍼
|
||||
const generateId = () => `cond_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// 조건 관리 헬퍼 함수들
|
||||
const addCondition = () => {
|
||||
const newCondition: ConditionNode = {
|
||||
id: generateId(),
|
||||
type: "condition",
|
||||
field: "",
|
||||
operator_type: "=",
|
||||
value: "",
|
||||
dataType: "string",
|
||||
operator: "AND", // 기본값으로 AND 설정
|
||||
logicalOperator: "AND", // 기본값으로 AND 설정
|
||||
};
|
||||
setConditions([...conditions, newCondition]);
|
||||
};
|
||||
|
||||
// 그룹 시작 추가
|
||||
const addGroupStart = () => {
|
||||
const groupId = `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const groupLevel = getNextGroupLevel();
|
||||
|
||||
const groupStart: ConditionNode = {
|
||||
id: generateId(),
|
||||
type: "group-start",
|
||||
groupId,
|
||||
groupLevel,
|
||||
logicalOperator: conditions.length > 0 ? "AND" : undefined,
|
||||
};
|
||||
|
||||
setConditions([...conditions, groupStart]);
|
||||
};
|
||||
|
||||
// 그룹 끝 추가
|
||||
const addGroupEnd = () => {
|
||||
// 가장 최근에 열린 그룹 찾기
|
||||
const openGroups = findOpenGroups();
|
||||
if (openGroups.length === 0) {
|
||||
toast.error("닫을 그룹이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lastOpenGroup = openGroups[openGroups.length - 1];
|
||||
const groupEnd: ConditionNode = {
|
||||
id: generateId(),
|
||||
type: "group-end",
|
||||
groupId: lastOpenGroup.groupId,
|
||||
groupLevel: lastOpenGroup.groupLevel,
|
||||
};
|
||||
|
||||
setConditions([...conditions, groupEnd]);
|
||||
};
|
||||
|
||||
// 다음 그룹 레벨 계산
|
||||
const getNextGroupLevel = (): number => {
|
||||
const openGroups = findOpenGroups();
|
||||
return openGroups.length;
|
||||
};
|
||||
|
||||
// 열린 그룹 찾기
|
||||
const findOpenGroups = () => {
|
||||
const openGroups: Array<{ groupId: string; groupLevel: number }> = [];
|
||||
|
||||
for (const condition of conditions) {
|
||||
if (condition.type === "group-start") {
|
||||
openGroups.push({
|
||||
groupId: condition.groupId!,
|
||||
groupLevel: condition.groupLevel!,
|
||||
});
|
||||
} else if (condition.type === "group-end") {
|
||||
// 해당 그룹 제거
|
||||
const groupIndex = openGroups.findIndex((g) => g.groupId === condition.groupId);
|
||||
if (groupIndex !== -1) {
|
||||
openGroups.splice(groupIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return openGroups;
|
||||
};
|
||||
|
||||
const updateCondition = (index: number, field: keyof ConditionNode, value: string) => {
|
||||
const updatedConditions = [...conditions];
|
||||
updatedConditions[index] = { ...updatedConditions[index], [field]: value };
|
||||
|
|
@ -466,8 +522,379 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
};
|
||||
|
||||
const removeCondition = (index: number) => {
|
||||
const conditionToRemove = conditions[index];
|
||||
|
||||
// 그룹 시작/끝을 삭제하는 경우 해당 그룹 전체 삭제
|
||||
if (conditionToRemove.type === "group-start" || conditionToRemove.type === "group-end") {
|
||||
removeGroup(conditionToRemove.groupId!);
|
||||
} else {
|
||||
const updatedConditions = conditions.filter((_, i) => i !== index);
|
||||
setConditions(updatedConditions);
|
||||
}
|
||||
};
|
||||
|
||||
// 그룹 전체 삭제
|
||||
const removeGroup = (groupId: string) => {
|
||||
const updatedConditions = conditions.filter((c) => c.groupId !== groupId);
|
||||
setConditions(updatedConditions);
|
||||
};
|
||||
|
||||
// 현재 조건의 그룹 레벨 계산
|
||||
const getCurrentGroupLevel = (conditionIndex: number): number => {
|
||||
let level = 0;
|
||||
for (let i = 0; i < conditionIndex; i++) {
|
||||
const condition = conditions[i];
|
||||
if (condition.type === "group-start") {
|
||||
level++;
|
||||
} else if (condition.type === "group-end") {
|
||||
level--;
|
||||
}
|
||||
}
|
||||
return level;
|
||||
};
|
||||
|
||||
// 액션별 조건 그룹 관리 함수들
|
||||
const addActionGroupStart = (actionIndex: number) => {
|
||||
const groupId = `action_group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const currentConditions = dataSaveSettings.actions[actionIndex].conditions || [];
|
||||
const groupLevel = getActionNextGroupLevel(currentConditions);
|
||||
|
||||
const groupStart: ConditionNode = {
|
||||
id: generateId(),
|
||||
type: "group-start",
|
||||
groupId,
|
||||
groupLevel,
|
||||
logicalOperator: currentConditions.length > 0 ? "AND" : undefined,
|
||||
};
|
||||
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions = [...currentConditions, groupStart];
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
};
|
||||
|
||||
const addActionGroupEnd = (actionIndex: number) => {
|
||||
const currentConditions = dataSaveSettings.actions[actionIndex].conditions || [];
|
||||
const openGroups = findActionOpenGroups(currentConditions);
|
||||
|
||||
if (openGroups.length === 0) {
|
||||
toast.error("닫을 그룹이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lastOpenGroup = openGroups[openGroups.length - 1];
|
||||
const groupEnd: ConditionNode = {
|
||||
id: generateId(),
|
||||
type: "group-end",
|
||||
groupId: lastOpenGroup.groupId,
|
||||
groupLevel: lastOpenGroup.groupLevel,
|
||||
};
|
||||
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions = [...currentConditions, groupEnd];
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
};
|
||||
|
||||
// 액션별 다음 그룹 레벨 계산
|
||||
const getActionNextGroupLevel = (conditions: ConditionNode[]): number => {
|
||||
const openGroups = findActionOpenGroups(conditions);
|
||||
return openGroups.length;
|
||||
};
|
||||
|
||||
// 액션별 열린 그룹 찾기
|
||||
const findActionOpenGroups = (conditions: ConditionNode[]) => {
|
||||
const openGroups: Array<{ groupId: string; groupLevel: number }> = [];
|
||||
|
||||
for (const condition of conditions) {
|
||||
if (condition.type === "group-start") {
|
||||
openGroups.push({
|
||||
groupId: condition.groupId!,
|
||||
groupLevel: condition.groupLevel!,
|
||||
});
|
||||
} else if (condition.type === "group-end") {
|
||||
const groupIndex = openGroups.findIndex((g) => g.groupId === condition.groupId);
|
||||
if (groupIndex !== -1) {
|
||||
openGroups.splice(groupIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return openGroups;
|
||||
};
|
||||
|
||||
// 액션별 현재 조건의 그룹 레벨 계산
|
||||
const getActionCurrentGroupLevel = (conditions: ConditionNode[], conditionIndex: number): number => {
|
||||
let level = 0;
|
||||
for (let i = 0; i < conditionIndex; i++) {
|
||||
const condition = conditions[i];
|
||||
if (condition.type === "group-start") {
|
||||
level++;
|
||||
} else if (condition.type === "group-end") {
|
||||
level--;
|
||||
}
|
||||
}
|
||||
return level;
|
||||
};
|
||||
|
||||
// 액션별 조건 렌더링 함수
|
||||
const renderActionCondition = (condition: ConditionNode, condIndex: number, actionIndex: number) => {
|
||||
// 그룹 시작 렌더링
|
||||
if (condition.type === "group-start") {
|
||||
return (
|
||||
<div key={condition.id} className="flex items-center gap-2">
|
||||
{condIndex > 0 && (
|
||||
<Select
|
||||
value={dataSaveSettings.actions[actionIndex].conditions![condIndex - 1]?.logicalOperator || "AND"}
|
||||
onValueChange={(value: "AND" | "OR") => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions![condIndex - 1].logicalOperator = value;
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-24 border-green-200 bg-green-50 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND</SelectItem>
|
||||
<SelectItem value="OR">OR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<div
|
||||
className="flex items-center gap-2 rounded border-2 border-dashed border-green-300 bg-green-50/50 p-1"
|
||||
style={{ marginLeft: `${(condition.groupLevel || 0) * 15}px` }}
|
||||
>
|
||||
<span className="font-mono text-xs text-green-600">(</span>
|
||||
<span className="text-xs text-green-600">그룹 시작</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions = newActions[actionIndex].conditions!.filter(
|
||||
(c) => c.groupId !== condition.groupId,
|
||||
);
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
className="h-4 w-4 p-0"
|
||||
>
|
||||
<Trash2 className="h-2 w-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 그룹 끝 렌더링
|
||||
if (condition.type === "group-end") {
|
||||
return (
|
||||
<div key={condition.id} className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2 rounded border-2 border-dashed border-green-300 bg-green-50/50 p-1"
|
||||
style={{ marginLeft: `${(condition.groupLevel || 0) * 15}px` }}
|
||||
>
|
||||
<span className="font-mono text-xs text-green-600">)</span>
|
||||
<span className="text-xs text-green-600">그룹 끝</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions = newActions[actionIndex].conditions!.filter(
|
||||
(c) => c.groupId !== condition.groupId,
|
||||
);
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
className="h-4 w-4 p-0"
|
||||
>
|
||||
<Trash2 className="h-2 w-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 조건 렌더링 (기존 로직 간소화)
|
||||
return (
|
||||
<div key={condition.id} className="flex items-center gap-2">
|
||||
{condIndex > 0 && (
|
||||
<Select
|
||||
value={dataSaveSettings.actions[actionIndex].conditions![condIndex - 1]?.logicalOperator || "AND"}
|
||||
onValueChange={(value: "AND" | "OR") => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions![condIndex - 1].logicalOperator = value;
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-24 border-green-200 bg-green-50 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND</SelectItem>
|
||||
<SelectItem value="OR">OR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<div
|
||||
className="flex flex-1 items-center gap-2 rounded border bg-white p-1"
|
||||
style={{
|
||||
marginLeft: `${getActionCurrentGroupLevel(dataSaveSettings.actions[actionIndex].conditions || [], condIndex) * 15}px`,
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
value={condition.field || ""}
|
||||
onValueChange={(value) => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions![condIndex].field = value;
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 flex-1 text-xs">
|
||||
<SelectValue placeholder="필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fromTableColumns.map((column) => (
|
||||
<SelectItem key={column.columnName} value={column.columnName}>
|
||||
{column.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={condition.operator_type || "="}
|
||||
onValueChange={(value: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE") => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions![condIndex].operator_type = value;
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-20 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="=">=</SelectItem>
|
||||
<SelectItem value="!=">!=</SelectItem>
|
||||
<SelectItem value=">">></SelectItem>
|
||||
<SelectItem value="<"><</SelectItem>
|
||||
<SelectItem value=">=">>=</SelectItem>
|
||||
<SelectItem value="<="><=</SelectItem>
|
||||
<SelectItem value="LIKE">LIKE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 데이터 타입에 따른 동적 입력 컴포넌트 */}
|
||||
{(() => {
|
||||
const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field);
|
||||
const dataType = selectedColumn?.dataType?.toLowerCase() || "string";
|
||||
|
||||
if (dataType.includes("timestamp") || dataType.includes("datetime") || dataType.includes("date")) {
|
||||
return (
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={condition.value || ""}
|
||||
onChange={(e) => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions![condIndex].value = e.target.value;
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
className="h-6 flex-1 text-xs"
|
||||
/>
|
||||
);
|
||||
} else if (dataType.includes("time")) {
|
||||
return (
|
||||
<Input
|
||||
type="time"
|
||||
value={condition.value || ""}
|
||||
onChange={(e) => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions![condIndex].value = e.target.value;
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
className="h-6 flex-1 text-xs"
|
||||
/>
|
||||
);
|
||||
} else if (dataType.includes("date")) {
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
value={condition.value || ""}
|
||||
onChange={(e) => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions![condIndex].value = e.target.value;
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
className="h-6 flex-1 text-xs"
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
dataType.includes("int") ||
|
||||
dataType.includes("numeric") ||
|
||||
dataType.includes("decimal") ||
|
||||
dataType.includes("float") ||
|
||||
dataType.includes("double")
|
||||
) {
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="숫자"
|
||||
value={condition.value || ""}
|
||||
onChange={(e) => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions![condIndex].value = e.target.value;
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
className="h-6 flex-1 text-xs"
|
||||
/>
|
||||
);
|
||||
} else if (dataType.includes("bool")) {
|
||||
return (
|
||||
<Select
|
||||
value={condition.value || ""}
|
||||
onValueChange={(value) => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions![condIndex].value = value;
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 flex-1 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">TRUE</SelectItem>
|
||||
<SelectItem value="false">FALSE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Input
|
||||
placeholder="값"
|
||||
value={condition.value || ""}
|
||||
onChange={(e) => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions![condIndex].value = e.target.value;
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
className="h-6 flex-1 text-xs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions = newActions[actionIndex].conditions!.filter(
|
||||
(_, i) => i !== condIndex,
|
||||
);
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-2 w-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 조건부 연결 설정 UI 렌더링
|
||||
|
|
@ -483,10 +910,18 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
<div className="mb-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">실행 조건</Label>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="outline" onClick={() => addCondition()} className="h-7 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
조건 추가
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => addGroupStart()} className="h-7 text-xs">
|
||||
그룹 시작 (
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => addGroupEnd()} className="h-7 text-xs">
|
||||
그룹 끝 )
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조건 목록 */}
|
||||
|
|
@ -498,13 +933,16 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
조건이 없으면 항상 실행됩니다.
|
||||
</div>
|
||||
) : (
|
||||
conditions.map((condition, index) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded border bg-white p-2">
|
||||
{/* 첫 번째 조건이 아닐 때 AND/OR 연산자를 앞에 표시 */}
|
||||
conditions.map((condition, index) => {
|
||||
// 그룹 시작 렌더링
|
||||
if (condition.type === "group-start") {
|
||||
return (
|
||||
<div key={condition.id} className="flex items-center gap-2">
|
||||
{/* 이전 조건과의 논리 연산자 */}
|
||||
{index > 0 && (
|
||||
<Select
|
||||
value={conditions[index - 1]?.operator || "AND"}
|
||||
onValueChange={(value: "AND" | "OR") => updateCondition(index - 1, "operator", value)}
|
||||
value={conditions[index - 1]?.logicalOperator || "AND"}
|
||||
onValueChange={(value: "AND" | "OR") => updateCondition(index - 1, "logicalOperator", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24 border-blue-200 bg-blue-50 text-xs">
|
||||
<SelectValue />
|
||||
|
|
@ -516,7 +954,74 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
</Select>
|
||||
)}
|
||||
|
||||
{/* 조건 필드들 */}
|
||||
{/* 그룹 레벨에 따른 들여쓰기 */}
|
||||
<div
|
||||
className="flex items-center gap-2 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 p-2"
|
||||
style={{ marginLeft: `${(condition.groupLevel || 0) * 20}px` }}
|
||||
>
|
||||
<span className="font-mono text-sm text-blue-600">(</span>
|
||||
<span className="text-xs text-blue-600">그룹 시작</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeCondition(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 그룹 끝 렌더링
|
||||
if (condition.type === "group-end") {
|
||||
return (
|
||||
<div key={condition.id} className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 p-2"
|
||||
style={{ marginLeft: `${(condition.groupLevel || 0) * 20}px` }}
|
||||
>
|
||||
<span className="font-mono text-sm text-blue-600">)</span>
|
||||
<span className="text-xs text-blue-600">그룹 끝</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeCondition(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 조건 렌더링
|
||||
return (
|
||||
<div key={condition.id} className="flex items-center gap-2">
|
||||
{/* 이전 조건과의 논리 연산자 */}
|
||||
{index > 0 && (
|
||||
<Select
|
||||
value={conditions[index - 1]?.logicalOperator || "AND"}
|
||||
onValueChange={(value: "AND" | "OR") => updateCondition(index - 1, "logicalOperator", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24 border-blue-200 bg-blue-50 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND</SelectItem>
|
||||
<SelectItem value="OR">OR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* 그룹 레벨에 따른 들여쓰기와 조건 필드들 */}
|
||||
<div
|
||||
className="flex flex-1 items-center gap-2 rounded border bg-white p-2"
|
||||
style={{ marginLeft: `${getCurrentGroupLevel(index) * 20}px` }}
|
||||
>
|
||||
{/* 조건 필드 선택 */}
|
||||
<Select
|
||||
value={condition.field || ""}
|
||||
onValueChange={(value) => updateCondition(index, "field", value)}
|
||||
|
|
@ -533,6 +1038,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<Select
|
||||
value={condition.operator_type || "="}
|
||||
onValueChange={(value) => updateCondition(index, "operator_type", value)}
|
||||
|
|
@ -551,12 +1057,16 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 데이터 타입에 따른 적절한 입력 컴포넌트 */}
|
||||
{/* 데이터 타입에 따른 동적 입력 컴포넌트 */}
|
||||
{(() => {
|
||||
const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field);
|
||||
const dataType = selectedColumn?.dataType?.toLowerCase() || "string";
|
||||
|
||||
if (dataType.includes("timestamp") || dataType.includes("datetime") || dataType.includes("date")) {
|
||||
if (
|
||||
dataType.includes("timestamp") ||
|
||||
dataType.includes("datetime") ||
|
||||
dataType.includes("date")
|
||||
) {
|
||||
return (
|
||||
<Input
|
||||
type="datetime-local"
|
||||
|
|
@ -626,11 +1136,14 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
}
|
||||
})()}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button size="sm" variant="ghost" onClick={() => removeCondition(index)} className="h-8 w-8 p-0">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -795,6 +1308,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
</summary>
|
||||
<div className="mt-2 space-y-2 border-l-2 border-gray-100 pl-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -805,7 +1319,15 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
}
|
||||
newActions[actionIndex].conditions = [
|
||||
...(newActions[actionIndex].conditions || []),
|
||||
{ field: "", operator: "=", value: "", logicalOperator: "AND" },
|
||||
{
|
||||
id: generateId(),
|
||||
type: "condition",
|
||||
field: "",
|
||||
operator_type: "=",
|
||||
value: "",
|
||||
dataType: "string",
|
||||
logicalOperator: "AND",
|
||||
},
|
||||
];
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
|
|
@ -814,192 +1336,29 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
<Plus className="mr-1 h-2 w-2" />
|
||||
조건 추가
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => addActionGroupStart(actionIndex)}
|
||||
className="h-6 text-xs"
|
||||
>
|
||||
그룹 시작 (
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => addActionGroupEnd(actionIndex)}
|
||||
className="h-6 text-xs"
|
||||
>
|
||||
그룹 끝 )
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{action.conditions && action.conditions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{action.conditions.map((condition, condIndex) => (
|
||||
<div key={condIndex} className="flex items-center gap-2">
|
||||
{/* 첫 번째 조건이 아닐 때 AND/OR 연산자를 앞에 표시 */}
|
||||
{condIndex > 0 && (
|
||||
<Select
|
||||
value={action.conditions![condIndex - 1].logicalOperator || "AND"}
|
||||
onValueChange={(value: "AND" | "OR") => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions![condIndex - 1].logicalOperator = value;
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-20 border-green-200 bg-green-50 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND</SelectItem>
|
||||
<SelectItem value="OR">OR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{action.conditions.map((condition, condIndex) =>
|
||||
renderActionCondition(condition, condIndex, actionIndex),
|
||||
)}
|
||||
|
||||
{/* 조건 필드들 */}
|
||||
<Select
|
||||
value={condition.field}
|
||||
onValueChange={(value) => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions![condIndex].field = value;
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 flex-1 text-xs">
|
||||
<SelectValue placeholder="필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fromTableColumns.map((column) => (
|
||||
<SelectItem key={column.columnName} value={column.columnName}>
|
||||
{column.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onValueChange={(value: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE") => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions![condIndex].operator = value;
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-16 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="=">=</SelectItem>
|
||||
<SelectItem value="!=">!=</SelectItem>
|
||||
<SelectItem value=">">></SelectItem>
|
||||
<SelectItem value="<"><</SelectItem>
|
||||
<SelectItem value=">=">>=</SelectItem>
|
||||
<SelectItem value="<="><=</SelectItem>
|
||||
<SelectItem value="LIKE">LIKE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 데이터 타입에 따른 적절한 입력 컴포넌트 */}
|
||||
{(() => {
|
||||
const selectedColumn = fromTableColumns.find(
|
||||
(col) => col.columnName === condition.field,
|
||||
);
|
||||
const dataType = selectedColumn?.dataType?.toLowerCase() || "string";
|
||||
|
||||
if (
|
||||
dataType.includes("timestamp") ||
|
||||
dataType.includes("datetime") ||
|
||||
dataType.includes("date")
|
||||
) {
|
||||
return (
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={condition.value}
|
||||
onChange={(e) => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions![condIndex].value = e.target.value;
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
className="h-6 flex-1 text-xs"
|
||||
/>
|
||||
);
|
||||
} else if (dataType.includes("time")) {
|
||||
return (
|
||||
<Input
|
||||
type="time"
|
||||
value={condition.value}
|
||||
onChange={(e) => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions![condIndex].value = e.target.value;
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
className="h-6 flex-1 text-xs"
|
||||
/>
|
||||
);
|
||||
} else if (dataType.includes("date")) {
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
value={condition.value}
|
||||
onChange={(e) => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions![condIndex].value = e.target.value;
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
className="h-6 flex-1 text-xs"
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
dataType.includes("int") ||
|
||||
dataType.includes("numeric") ||
|
||||
dataType.includes("decimal") ||
|
||||
dataType.includes("float") ||
|
||||
dataType.includes("double")
|
||||
) {
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="숫자"
|
||||
value={condition.value}
|
||||
onChange={(e) => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions![condIndex].value = e.target.value;
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
className="h-6 flex-1 text-xs"
|
||||
/>
|
||||
);
|
||||
} else if (dataType.includes("bool")) {
|
||||
return (
|
||||
<Select
|
||||
value={condition.value}
|
||||
onValueChange={(value) => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions![condIndex].value = value;
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 flex-1 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">TRUE</SelectItem>
|
||||
<SelectItem value="false">FALSE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Input
|
||||
placeholder="값"
|
||||
value={condition.value}
|
||||
onChange={(e) => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions![condIndex].value = e.target.value;
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
className="h-6 flex-1 text-xs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions = newActions[
|
||||
actionIndex
|
||||
].conditions!.filter((_, i) => i !== condIndex);
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-2 w-2" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,17 +5,19 @@ import { apiClient, ApiResponse } from "./client";
|
|||
// 조건부 연결 관련 타입들
|
||||
export interface ConditionControl {
|
||||
triggerType: "insert" | "update" | "delete" | "insert_update";
|
||||
conditionTree: ConditionNode;
|
||||
conditionTree: ConditionNode | ConditionNode[] | null;
|
||||
}
|
||||
|
||||
export interface ConditionNode {
|
||||
type: "group" | "condition";
|
||||
operator?: "AND" | "OR";
|
||||
children?: ConditionNode[];
|
||||
id: string; // 고유 ID
|
||||
type: "condition" | "group-start" | "group-end";
|
||||
field?: string;
|
||||
operator_type?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value?: any;
|
||||
dataType?: string;
|
||||
logicalOperator?: "AND" | "OR"; // 다음 조건과의 논리 연산자
|
||||
groupId?: string; // 그룹 ID (group-start와 group-end가 같은 groupId를 가짐)
|
||||
groupLevel?: number; // 중첩 레벨 (0, 1, 2, ...)
|
||||
}
|
||||
|
||||
export interface ConnectionCategory {
|
||||
|
|
|
|||
Loading…
Reference in New Issue