조건 그룹핑 구현

This commit is contained in:
hyeonsu 2025-09-15 11:17:46 +09:00
parent dbad9bbc0c
commit 41f40ac216
3 changed files with 819 additions and 385 deletions

View File

@ -5,19 +5,21 @@ const prisma = new PrismaClient();
// 조건 노드 타입 정의 // 조건 노드 타입 정의
interface ConditionNode { interface ConditionNode {
type: "group" | "condition"; id: string; // 고유 ID
operator?: "AND" | "OR"; type: "condition" | "group-start" | "group-end";
children?: ConditionNode[];
field?: string; field?: string;
operator_type?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE"; operator_type?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
value?: any; value?: any;
dataType?: string; dataType?: string;
logicalOperator?: "AND" | "OR"; // 다음 조건과의 논리 연산자
groupId?: string; // 그룹 ID (group-start와 group-end가 같은 groupId를 가짐)
groupLevel?: number; // 중첩 레벨 (0, 1, 2, ...)
} }
// 조건 제어 정보 // 조건 제어 정보
interface ConditionControl { interface ConditionControl {
triggerType: "insert" | "update" | "delete" | "insert_update"; triggerType: "insert" | "update" | "delete" | "insert_update";
conditionTree: ConditionNode | null; conditionTree: ConditionNode | ConditionNode[] | null;
} }
// 연결 카테고리 정보 // 연결 카테고리 정보
@ -237,34 +239,105 @@ export class EventTriggerService {
} }
/** /**
* * ( + )
*/ */
private static async evaluateCondition( private static async evaluateCondition(
condition: ConditionNode, condition: ConditionNode | ConditionNode[],
data: Record<string, any> data: Record<string, any>
): Promise<boolean> { ): 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; return true;
} }
const results = await Promise.all( // 조건 배열인 경우 (새로운 그룹핑 시스템)
condition.children.map((child) => this.evaluateCondition(child, data)) return this.evaluateConditionList(condition, data);
);
if (condition.operator === "OR") {
return results.some((result) => result);
} else {
// AND
return results.every((result) => result);
} }
/**
* ( )
*/
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") { } 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; 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 ) * (AND/OR )
*/ */

View File

@ -62,12 +62,7 @@ interface DataSaveSettings {
id: string; id: string;
name: string; name: string;
actionType: "insert" | "update" | "delete" | "upsert"; actionType: "insert" | "update" | "delete" | "upsert";
conditions?: Array<{ conditions?: ConditionNode[];
field: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
value: string;
logicalOperator?: "AND" | "OR";
}>;
fieldMappings: Array<{ fieldMappings: Array<{
sourceTable?: string; sourceTable?: string;
sourceField: string; sourceField: string;
@ -353,14 +348,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
? { ? {
control: { control: {
triggerType: "insert", triggerType: "insert",
conditionTree: conditionTree: conditions.length > 0 ? conditions : null,
conditions.length > 0
? {
type: "group" as const,
operator: "AND" as const,
children: conditions,
}
: null,
}, },
category: { category: {
type: config.connectionType, type: config.connectionType,
@ -446,19 +434,87 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
return config.connectionType === "data-save" || config.connectionType === "external-call"; 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 addCondition = () => {
const newCondition: ConditionNode = { const newCondition: ConditionNode = {
id: generateId(),
type: "condition", type: "condition",
field: "", field: "",
operator_type: "=", operator_type: "=",
value: "", value: "",
dataType: "string", dataType: "string",
operator: "AND", // 기본값으로 AND 설정 logicalOperator: "AND", // 기본값으로 AND 설정
}; };
setConditions([...conditions, newCondition]); 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 updateCondition = (index: number, field: keyof ConditionNode, value: string) => {
const updatedConditions = [...conditions]; const updatedConditions = [...conditions];
updatedConditions[index] = { ...updatedConditions[index], [field]: value }; updatedConditions[index] = { ...updatedConditions[index], [field]: value };
@ -466,8 +522,379 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
}; };
const removeCondition = (index: number) => { 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); const updatedConditions = conditions.filter((_, i) => i !== index);
setConditions(updatedConditions); 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=">">&gt;</SelectItem>
<SelectItem value="<">&lt;</SelectItem>
<SelectItem value=">=">&gt;=</SelectItem>
<SelectItem value="<=">&lt;=</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 렌더링 // 조건부 연결 설정 UI 렌더링
@ -483,10 +910,18 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
<div className="mb-4"> <div className="mb-4">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<Label className="text-sm font-medium"> </Label> <Label className="text-sm font-medium"> </Label>
<div className="flex gap-1">
<Button size="sm" variant="outline" onClick={() => addCondition()} className="h-7 text-xs"> <Button size="sm" variant="outline" onClick={() => addCondition()} className="h-7 text-xs">
<Plus className="mr-1 h-3 w-3" /> <Plus className="mr-1 h-3 w-3" />
</Button> </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> </div>
{/* 조건 목록 */} {/* 조건 목록 */}
@ -498,13 +933,16 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
. .
</div> </div>
) : ( ) : (
conditions.map((condition, index) => ( conditions.map((condition, index) => {
<div key={index} className="flex items-center gap-2 rounded border bg-white p-2"> // 그룹 시작 렌더링
{/* 첫 번째 조건이 아닐 때 AND/OR 연산자를 앞에 표시 */} if (condition.type === "group-start") {
return (
<div key={condition.id} className="flex items-center gap-2">
{/* 이전 조건과의 논리 연산자 */}
{index > 0 && ( {index > 0 && (
<Select <Select
value={conditions[index - 1]?.operator || "AND"} value={conditions[index - 1]?.logicalOperator || "AND"}
onValueChange={(value: "AND" | "OR") => updateCondition(index - 1, "operator", value)} onValueChange={(value: "AND" | "OR") => updateCondition(index - 1, "logicalOperator", value)}
> >
<SelectTrigger className="h-8 w-24 border-blue-200 bg-blue-50 text-xs"> <SelectTrigger className="h-8 w-24 border-blue-200 bg-blue-50 text-xs">
<SelectValue /> <SelectValue />
@ -516,7 +954,74 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
</Select> </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 <Select
value={condition.field || ""} value={condition.field || ""}
onValueChange={(value) => updateCondition(index, "field", value)} onValueChange={(value) => updateCondition(index, "field", value)}
@ -533,6 +1038,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
</SelectContent> </SelectContent>
</Select> </Select>
{/* 연산자 선택 */}
<Select <Select
value={condition.operator_type || "="} value={condition.operator_type || "="}
onValueChange={(value) => updateCondition(index, "operator_type", value)} onValueChange={(value) => updateCondition(index, "operator_type", value)}
@ -551,12 +1057,16 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
</SelectContent> </SelectContent>
</Select> </Select>
{/* 데이터 타입에 따른 절한 입력 컴포넌트 */} {/* 데이터 타입에 따른 적 입력 컴포넌트 */}
{(() => { {(() => {
const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field); const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field);
const dataType = selectedColumn?.dataType?.toLowerCase() || "string"; 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 ( return (
<Input <Input
type="datetime-local" 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"> <Button size="sm" variant="ghost" onClick={() => removeCondition(index)} className="h-8 w-8 p-0">
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
</div> </div>
)) </div>
);
})
)} )}
</div> </div>
</div> </div>
@ -795,6 +1308,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
</summary> </summary>
<div className="mt-2 space-y-2 border-l-2 border-gray-100 pl-4"> <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="mb-2 flex items-center justify-between">
<div className="flex gap-1">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@ -805,7 +1319,15 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
} }
newActions[actionIndex].conditions = [ newActions[actionIndex].conditions = [
...(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 }); setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}} }}
@ -814,192 +1336,29 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
<Plus className="mr-1 h-2 w-2" /> <Plus className="mr-1 h-2 w-2" />
</Button> </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> </div>
{action.conditions && action.conditions.length > 0 && ( {action.conditions && action.conditions.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
{action.conditions.map((condition, condIndex) => ( {action.conditions.map((condition, condIndex) =>
<div key={condIndex} className="flex items-center gap-2"> renderActionCondition(condition, condIndex, actionIndex),
{/* 첫 번째 조건이 아닐 때 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>
)} )}
{/* 조건 필드들 */}
<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=">">&gt;</SelectItem>
<SelectItem value="<">&lt;</SelectItem>
<SelectItem value=">=">&gt;=</SelectItem>
<SelectItem value="<=">&lt;=</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>
)} )}
</div> </div>

View File

@ -5,17 +5,19 @@ import { apiClient, ApiResponse } from "./client";
// 조건부 연결 관련 타입들 // 조건부 연결 관련 타입들
export interface ConditionControl { export interface ConditionControl {
triggerType: "insert" | "update" | "delete" | "insert_update"; triggerType: "insert" | "update" | "delete" | "insert_update";
conditionTree: ConditionNode; conditionTree: ConditionNode | ConditionNode[] | null;
} }
export interface ConditionNode { export interface ConditionNode {
type: "group" | "condition"; id: string; // 고유 ID
operator?: "AND" | "OR"; type: "condition" | "group-start" | "group-end";
children?: ConditionNode[];
field?: string; field?: string;
operator_type?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE"; operator_type?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
value?: any; value?: any;
dataType?: string; dataType?: string;
logicalOperator?: "AND" | "OR"; // 다음 조건과의 논리 연산자
groupId?: string; // 그룹 ID (group-start와 group-end가 같은 groupId를 가짐)
groupLevel?: number; // 중첩 레벨 (0, 1, 2, ...)
} }
export interface ConnectionCategory { export interface ConnectionCategory {