제어관리 개선판
This commit is contained in:
@@ -28,9 +28,9 @@ export function PropertiesPanel() {
|
||||
const selectedNode = selectedNodes.length === 1 ? nodes.find((n) => n.id === selectedNodes[0]) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div className="flex h-16 shrink-0 items-center justify-between border-b bg-white p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">속성</h3>
|
||||
{selectedNode && (
|
||||
@@ -42,8 +42,15 @@ export function PropertiesPanel() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{/* 내용 - 스크롤 가능 영역 */}
|
||||
<div
|
||||
className="flex-1 overflow-y-scroll"
|
||||
style={{
|
||||
maxHeight: 'calc(100vh - 64px)',
|
||||
overflowY: 'scroll',
|
||||
WebkitOverflowScrolling: 'touch'
|
||||
}}
|
||||
>
|
||||
{selectedNodes.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
|
||||
@@ -29,7 +29,7 @@ export function CommentProperties({ nodeId, data }: CommentPropertiesProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
<div className="flex items-center gap-2 rounded-md bg-yellow-50 p-2">
|
||||
<MessageSquare className="h-4 w-4 text-yellow-600" />
|
||||
<span className="font-semibold text-yellow-600">주석</span>
|
||||
|
||||
@@ -183,26 +183,39 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (index: number) => {
|
||||
setConditions(conditions.filter((_, i) => i !== index));
|
||||
const newConditions = conditions.filter((_, i) => i !== index);
|
||||
setConditions(newConditions);
|
||||
updateNode(nodeId, {
|
||||
conditions: newConditions,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDisplayNameChange = (newDisplayName: string) => {
|
||||
setDisplayName(newDisplayName);
|
||||
updateNode(nodeId, {
|
||||
displayName: newDisplayName,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConditionChange = (index: number, field: string, value: any) => {
|
||||
const newConditions = [...conditions];
|
||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||
setConditions(newConditions);
|
||||
updateNode(nodeId, {
|
||||
conditions: newConditions,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const handleLogicChange = (newLogic: "AND" | "OR") => {
|
||||
setLogic(newLogic);
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
conditions,
|
||||
logic,
|
||||
logic: newLogic,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
@@ -215,7 +228,7 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
onChange={(e) => handleDisplayNameChange(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
@@ -225,7 +238,7 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||
<Label htmlFor="logic" className="text-xs">
|
||||
조건 로직
|
||||
</Label>
|
||||
<Select value={logic} onValueChange={(value: "AND" | "OR") => setLogic(value)}>
|
||||
<Select value={logic} onValueChange={handleLogicChange}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -386,12 +399,6 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} className="flex-1" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -359,7 +359,7 @@ export function DataTransformProperties({ nodeId, data }: DataTransformPropertie
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-md bg-indigo-50 p-2">
|
||||
<Wand2 className="h-4 w-4 text-indigo-600" />
|
||||
@@ -453,13 +453,6 @@ export function DataTransformProperties({ nodeId, data }: DataTransformPropertie
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 적용 버튼 */}
|
||||
<div className="sticky bottom-0 border-t bg-white pt-3">
|
||||
<Button onClick={handleSave} className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
<p className="mt-2 text-center text-xs text-gray-500">✅ 변경 사항이 즉시 노드에 반영됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -217,7 +217,7 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 경고 */}
|
||||
<div className="rounded-lg border-2 border-red-200 bg-red-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -706,9 +706,6 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} variant="destructive" className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="rounded bg-red-50 p-3 text-xs text-red-700">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Database, RefreshCw } from "lucide-react";
|
||||
import { Database, RefreshCw, Table, FileText } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -43,6 +43,11 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
|
||||
const [selectedConnectionId, setSelectedConnectionId] = useState<number | undefined>(data.connectionId);
|
||||
const [tableName, setTableName] = useState(data.tableName);
|
||||
const [schema, setSchema] = useState(data.schema || "");
|
||||
|
||||
// 🆕 데이터 소스 타입 (기본값: context-data)
|
||||
const [dataSourceType, setDataSourceType] = useState<"context-data" | "table-all">(
|
||||
(data as any).dataSourceType || "context-data"
|
||||
);
|
||||
|
||||
const [connections, setConnections] = useState<ExternalConnection[]>([]);
|
||||
const [tables, setTables] = useState<ExternalTable[]>([]);
|
||||
@@ -200,21 +205,26 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const handleDisplayNameChange = (newDisplayName: string) => {
|
||||
setDisplayName(newDisplayName);
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
connectionId: selectedConnectionId,
|
||||
connectionName: selectedConnection?.connection_name || "",
|
||||
tableName,
|
||||
schema,
|
||||
dbType: selectedConnection?.db_type,
|
||||
displayName: newDisplayName,
|
||||
});
|
||||
toast.success("설정이 저장되었습니다.");
|
||||
};
|
||||
|
||||
/**
|
||||
* 🆕 데이터 소스 타입 변경 핸들러
|
||||
*/
|
||||
const handleDataSourceTypeChange = (newType: "context-data" | "table-all") => {
|
||||
setDataSourceType(newType);
|
||||
updateNode(nodeId, {
|
||||
dataSourceType: newType,
|
||||
});
|
||||
console.log(`✅ 데이터 소스 타입 변경: ${newType}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* DB 타입 정보 */}
|
||||
<div
|
||||
className="rounded-lg border-2 p-4"
|
||||
@@ -302,7 +312,7 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
onChange={(e) => handleDisplayNameChange(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
@@ -340,6 +350,64 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🆕 데이터 소스 설정 */}
|
||||
{tableName && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">데이터 소스 설정</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">데이터 소스 타입</Label>
|
||||
<Select value={dataSourceType} onValueChange={handleDataSourceTypeChange}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="데이터 소스 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="context-data">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">컨텍스트 데이터</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
버튼에서 전달된 데이터 사용
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="table-all">
|
||||
<div className="flex items-center gap-2">
|
||||
<Table className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">테이블 전체 데이터</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
외부 DB의 모든 행 조회
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 설명 텍스트 */}
|
||||
<div className="mt-2 rounded bg-blue-50 p-3 text-xs text-blue-700">
|
||||
{dataSourceType === "context-data" ? (
|
||||
<>
|
||||
<p className="font-medium mb-1">💡 컨텍스트 데이터 모드</p>
|
||||
<p>버튼 실행 시 전달된 데이터를 사용합니다.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium mb-1">📊 테이블 전체 데이터 모드</p>
|
||||
<p>외부 DB의 **모든 행**을 직접 조회합니다.</p>
|
||||
<p className="mt-1 text-orange-600 font-medium">⚠️ 대량 데이터 시 성능 주의</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 정보 */}
|
||||
{columns.length > 0 && (
|
||||
<div>
|
||||
@@ -347,14 +415,16 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
|
||||
{loadingColumns ? (
|
||||
<p className="text-xs text-gray-500">컬럼 목록 로딩 중... ⏳</p>
|
||||
) : (
|
||||
<div className="max-h-[200px] space-y-1 overflow-y-auto">
|
||||
<div className="max-h-[300px] space-y-1 overflow-y-auto rounded border bg-gray-50 p-2">
|
||||
{columns.map((col, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between rounded border bg-gray-50 px-3 py-2 text-xs"
|
||||
className="flex items-center justify-between rounded bg-white px-2 py-1.5 text-xs"
|
||||
>
|
||||
<span className="font-medium">{col.column_name}</span>
|
||||
<span className="font-mono text-gray-500">{col.data_type}</span>
|
||||
<span className="truncate font-medium" title={col.column_name}>
|
||||
{col.column_name}
|
||||
</span>
|
||||
<span className="ml-2 shrink-0 font-mono text-gray-500">{col.data_type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -362,14 +432,9 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
|
||||
<div className="rounded p-3 text-xs" style={{ backgroundColor: `${dbInfo.color}15`, color: dbInfo.color }}>
|
||||
💡 외부 DB 연결은 "외부 DB 연결 관리" 메뉴에서 미리 설정해야 합니다.
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -451,31 +451,22 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||
};
|
||||
|
||||
const handleAddMapping = () => {
|
||||
setFieldMappings([
|
||||
const newMappings = [
|
||||
...fieldMappings,
|
||||
{
|
||||
sourceField: null,
|
||||
targetField: "",
|
||||
staticValue: undefined,
|
||||
},
|
||||
]);
|
||||
];
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (index: number) => {
|
||||
const newMappings = fieldMappings.filter((_, i) => i !== index);
|
||||
setFieldMappings(newMappings);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
fieldMappings: newMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
ignoreDuplicates,
|
||||
},
|
||||
});
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleMappingChange = (index: number, field: string, value: any) => {
|
||||
@@ -490,28 +481,71 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||
sourceFieldLabel: sourceField?.label,
|
||||
};
|
||||
} else if (field === "targetField") {
|
||||
const targetColumn = targetColumns.find((c) => c.columnName === value);
|
||||
const targetColumn = (() => {
|
||||
if (targetType === "internal") {
|
||||
return targetColumns.find((col) => col.column_name === value);
|
||||
} else if (targetType === "external") {
|
||||
return externalColumns.find((col) => col.column_name === value);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
newMappings[index] = {
|
||||
...newMappings[index],
|
||||
targetField: value,
|
||||
targetFieldLabel: targetColumn?.columnLabel,
|
||||
targetFieldLabel: targetColumn?.label_ko || targetColumn?.column_label || targetColumn?.displayName || value,
|
||||
};
|
||||
} else {
|
||||
newMappings[index] = { ...newMappings[index], [field]: value };
|
||||
newMappings[index] = {
|
||||
...newMappings[index],
|
||||
[field]: value,
|
||||
};
|
||||
}
|
||||
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// 즉시 반영 핸들러들
|
||||
const handleDisplayNameChange = (newDisplayName: string) => {
|
||||
setDisplayName(newDisplayName);
|
||||
updateNode(nodeId, { displayName: newDisplayName });
|
||||
};
|
||||
|
||||
const handleFieldMappingsChange = (newMappings: any[]) => {
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleBatchSizeChange = (newBatchSize: string) => {
|
||||
setBatchSize(newBatchSize);
|
||||
updateNode(nodeId, {
|
||||
options: {
|
||||
batchSize: newBatchSize ? parseInt(newBatchSize) : undefined,
|
||||
ignoreErrors,
|
||||
ignoreDuplicates,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleIgnoreErrorsChange = (checked: boolean) => {
|
||||
setIgnoreErrors(checked);
|
||||
updateNode(nodeId, {
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors: checked,
|
||||
ignoreDuplicates,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleIgnoreDuplicatesChange = (checked: boolean) => {
|
||||
setIgnoreDuplicates(checked);
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
fieldMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
ignoreDuplicates,
|
||||
ignoreDuplicates: checked,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -552,7 +586,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 🔥 타겟 타입 선택 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium">타겟 선택</Label>
|
||||
@@ -1219,7 +1253,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||
id="batchSize"
|
||||
type="number"
|
||||
value={batchSize}
|
||||
onChange={(e) => setBatchSize(e.target.value)}
|
||||
onChange={(e) => handleBatchSizeChange(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="한 번에 처리할 레코드 수"
|
||||
/>
|
||||
@@ -1229,7 +1263,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||
<Checkbox
|
||||
id="ignoreDuplicates"
|
||||
checked={ignoreDuplicates}
|
||||
onCheckedChange={(checked) => setIgnoreDuplicates(checked as boolean)}
|
||||
onCheckedChange={(checked) => handleIgnoreDuplicatesChange(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="ignoreDuplicates" className="text-xs font-normal">
|
||||
중복 데이터 무시
|
||||
@@ -1240,7 +1274,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||
<Checkbox
|
||||
id="ignoreErrors"
|
||||
checked={ignoreErrors}
|
||||
onCheckedChange={(checked) => setIgnoreErrors(checked as boolean)}
|
||||
onCheckedChange={(checked) => handleIgnoreErrorsChange(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="ignoreErrors" className="text-xs font-normal">
|
||||
오류 발생 시 계속 진행
|
||||
@@ -1250,11 +1284,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} className="flex-1" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
||||
|
||||
@@ -47,7 +47,7 @@ export function LogProperties({ nodeId, data }: LogPropertiesProps) {
|
||||
const LevelIcon = selectedLevel?.icon || Info;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
<div className="flex items-center gap-2 rounded-md bg-gray-50 p-2">
|
||||
<FileText className="h-4 w-4 text-gray-600" />
|
||||
<span className="font-semibold text-gray-600">로그</span>
|
||||
|
||||
@@ -263,7 +263,7 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
@@ -619,11 +619,6 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} className="flex-1" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -124,7 +124,7 @@ export function RestAPISourceProperties({ nodeId, data }: RestAPISourcePropertie
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
<div className="flex items-center gap-2 rounded-md bg-teal-50 p-2">
|
||||
<Globe className="h-4 w-4 text-teal-600" />
|
||||
<span className="font-semibold text-teal-600">REST API 소스</span>
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Check, ChevronsUpDown, Table, FileText } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
@@ -34,6 +35,11 @@ export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesPro
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || data.tableName);
|
||||
const [tableName, setTableName] = useState(data.tableName);
|
||||
|
||||
// 🆕 데이터 소스 타입 (기본값: context-data)
|
||||
const [dataSourceType, setDataSourceType] = useState<"context-data" | "table-all">(
|
||||
(data as any).dataSourceType || "context-data"
|
||||
);
|
||||
|
||||
// 테이블 선택 관련 상태
|
||||
const [tables, setTables] = useState<TableOption[]>([]);
|
||||
@@ -44,7 +50,8 @@ export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesPro
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || data.tableName);
|
||||
setTableName(data.tableName);
|
||||
}, [data.displayName, data.tableName]);
|
||||
setDataSourceType((data as any).dataSourceType || "context-data");
|
||||
}, [data.displayName, data.tableName, (data as any).dataSourceType]);
|
||||
|
||||
// 테이블 목록 로딩
|
||||
useEffect(() => {
|
||||
@@ -145,12 +152,22 @@ export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesPro
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 🆕 데이터 소스 타입 변경 핸들러
|
||||
*/
|
||||
const handleDataSourceTypeChange = (newType: "context-data" | "table-all") => {
|
||||
setDataSourceType(newType);
|
||||
updateNode(nodeId, {
|
||||
dataSourceType: newType,
|
||||
});
|
||||
console.log(`✅ 데이터 소스 타입 변경: ${newType}`);
|
||||
};
|
||||
|
||||
// 현재 선택된 테이블의 라벨 찾기
|
||||
const selectedTableLabel = tables.find((t) => t.tableName === tableName)?.label || tableName;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
@@ -237,15 +254,77 @@ export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesPro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🆕 데이터 소스 설정 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">데이터 소스 설정</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">데이터 소스 타입</Label>
|
||||
<Select value={dataSourceType} onValueChange={handleDataSourceTypeChange}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="데이터 소스 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="context-data">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">컨텍스트 데이터</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
버튼에서 전달된 데이터 사용 (폼, 선택 항목 등)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="table-all">
|
||||
<div className="flex items-center gap-2">
|
||||
<Table className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">테이블 전체 데이터</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
선택한 테이블의 모든 행 조회 (페이징 무관)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 설명 텍스트 */}
|
||||
<div className="mt-2 rounded bg-blue-50 p-3 text-xs text-blue-700">
|
||||
{dataSourceType === "context-data" ? (
|
||||
<>
|
||||
<p className="font-medium mb-1">💡 컨텍스트 데이터 모드</p>
|
||||
<p>버튼 실행 시 전달된 데이터(폼 데이터, 테이블 선택 항목 등)를 사용합니다.</p>
|
||||
<p className="mt-1 text-blue-600">• 폼 데이터: 1개 레코드</p>
|
||||
<p className="text-blue-600">• 테이블 선택: N개 레코드</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium mb-1">📊 테이블 전체 데이터 모드</p>
|
||||
<p>선택한 테이블의 **모든 행**을 직접 조회합니다.</p>
|
||||
<p className="mt-1 text-orange-600 font-medium">⚠️ 대량 데이터 시 성능 주의</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">출력 필드</h3>
|
||||
<h3 className="mb-3 text-sm font-semibold">
|
||||
출력 필드 {data.fields && data.fields.length > 0 && `(${data.fields.length}개)`}
|
||||
</h3>
|
||||
{data.fields && data.fields.length > 0 ? (
|
||||
<div className="space-y-1 rounded border p-2">
|
||||
<div className="max-h-[300px] space-y-1 overflow-y-auto rounded border bg-gray-50 p-2">
|
||||
{data.fields.map((field) => (
|
||||
<div key={field.name} className="flex items-center justify-between text-xs">
|
||||
<span className="font-mono text-gray-700">{field.name}</span>
|
||||
<span className="text-gray-400">{field.type}</span>
|
||||
<div key={field.name} className="flex items-center justify-between rounded bg-white px-2 py-1.5 text-xs">
|
||||
<span className="truncate font-mono text-gray-700" title={field.name}>
|
||||
{field.name}
|
||||
</span>
|
||||
<span className="ml-2 shrink-0 text-gray-400">{field.type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -254,9 +333,6 @@ export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesPro
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="rounded bg-green-50 p-3 text-xs text-green-700">✅ 변경 사항이 즉시 노드에 반영됩니다.</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -378,31 +378,22 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||
};
|
||||
|
||||
const handleAddMapping = () => {
|
||||
setFieldMappings([
|
||||
const newMappings = [
|
||||
...fieldMappings,
|
||||
{
|
||||
sourceField: null,
|
||||
targetField: "",
|
||||
staticValue: undefined,
|
||||
},
|
||||
]);
|
||||
];
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (index: number) => {
|
||||
const newMappings = fieldMappings.filter((_, i) => i !== index);
|
||||
setFieldMappings(newMappings);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
fieldMappings: newMappings,
|
||||
whereConditions,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
},
|
||||
});
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleMappingChange = (index: number, field: string, value: any) => {
|
||||
@@ -428,6 +419,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||
}
|
||||
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
// 🔥 타겟 타입 변경 핸들러
|
||||
@@ -459,31 +451,22 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||
};
|
||||
|
||||
const handleAddCondition = () => {
|
||||
setWhereConditions([
|
||||
const newConditions = [
|
||||
...whereConditions,
|
||||
{
|
||||
field: "",
|
||||
operator: "EQUALS",
|
||||
staticValue: "",
|
||||
},
|
||||
]);
|
||||
];
|
||||
setWhereConditions(newConditions);
|
||||
updateNode(nodeId, { whereConditions: newConditions });
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (index: number) => {
|
||||
const newConditions = whereConditions.filter((_, i) => i !== index);
|
||||
setWhereConditions(newConditions);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
fieldMappings,
|
||||
whereConditions: newConditions,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
},
|
||||
});
|
||||
updateNode(nodeId, { whereConditions: newConditions });
|
||||
};
|
||||
|
||||
const handleConditionChange = (index: number, field: string, value: any) => {
|
||||
@@ -509,17 +492,41 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||
}
|
||||
|
||||
setWhereConditions(newConditions);
|
||||
updateNode(nodeId, { whereConditions: newConditions });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// 즉시 반영 핸들러들
|
||||
const handleDisplayNameChange = (newDisplayName: string) => {
|
||||
setDisplayName(newDisplayName);
|
||||
updateNode(nodeId, { displayName: newDisplayName });
|
||||
};
|
||||
|
||||
const handleFieldMappingsChange = (newMappings: any[]) => {
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleWhereConditionsChange = (newConditions: any[]) => {
|
||||
setWhereConditions(newConditions);
|
||||
updateNode(nodeId, { whereConditions: newConditions });
|
||||
};
|
||||
|
||||
const handleBatchSizeChange = (newBatchSize: string) => {
|
||||
setBatchSize(newBatchSize);
|
||||
updateNode(nodeId, {
|
||||
options: {
|
||||
batchSize: newBatchSize ? parseInt(newBatchSize) : undefined,
|
||||
ignoreErrors,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleIgnoreErrorsChange = (checked: boolean) => {
|
||||
setIgnoreErrors(checked);
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
fieldMappings,
|
||||
whereConditions,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
ignoreErrors: checked,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -528,7 +535,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
@@ -1273,7 +1280,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||
id="batchSize"
|
||||
type="number"
|
||||
value={batchSize}
|
||||
onChange={(e) => setBatchSize(e.target.value)}
|
||||
onChange={(e) => handleBatchSizeChange(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="예: 100"
|
||||
/>
|
||||
@@ -1283,7 +1290,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||
<Checkbox
|
||||
id="ignoreErrors"
|
||||
checked={ignoreErrors}
|
||||
onCheckedChange={(checked) => setIgnoreErrors(checked as boolean)}
|
||||
onCheckedChange={(checked) => handleIgnoreErrorsChange(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="ignoreErrors" className="cursor-pointer text-xs font-normal">
|
||||
오류 무시
|
||||
@@ -1292,13 +1299,6 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 적용 버튼 */}
|
||||
<div className="sticky bottom-0 border-t bg-white pt-3">
|
||||
<Button onClick={handleSave} className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
<p className="mt-2 text-center text-xs text-gray-500">✅ 변경 사항이 즉시 노드에 반영됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -380,6 +380,10 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||
|
||||
setConflictKeys(newConflictKeys);
|
||||
setConflictKeyLabels(newConflictKeyLabels);
|
||||
updateNode(nodeId, {
|
||||
conflictKeys: newConflictKeys,
|
||||
conflictKeyLabels: newConflictKeyLabels,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -389,48 +393,29 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||
|
||||
setConflictKeys(newKeys);
|
||||
setConflictKeyLabels(newLabels);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
conflictKeys: newKeys,
|
||||
conflictKeyLabels: newLabels,
|
||||
fieldMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
updateOnConflict,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddMapping = () => {
|
||||
setFieldMappings([
|
||||
const newMappings = [
|
||||
...fieldMappings,
|
||||
{
|
||||
sourceField: null,
|
||||
targetField: "",
|
||||
staticValue: undefined,
|
||||
},
|
||||
]);
|
||||
];
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (index: number) => {
|
||||
const newMappings = fieldMappings.filter((_, i) => i !== index);
|
||||
setFieldMappings(newMappings);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
conflictKeys,
|
||||
conflictKeyLabels,
|
||||
fieldMappings: newMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
updateOnConflict,
|
||||
},
|
||||
});
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleMappingChange = (index: number, field: string, value: any) => {
|
||||
@@ -456,18 +441,41 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||
}
|
||||
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// 즉시 반영 핸들러들
|
||||
const handleDisplayNameChange = (newDisplayName: string) => {
|
||||
setDisplayName(newDisplayName);
|
||||
updateNode(nodeId, { displayName: newDisplayName });
|
||||
};
|
||||
|
||||
const handleConflictKeysChange = (newKeys: string[]) => {
|
||||
setConflictKeys(newKeys);
|
||||
updateNode(nodeId, { conflictKeys: newKeys });
|
||||
};
|
||||
|
||||
const handleFieldMappingsChange = (newMappings: any[]) => {
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleBatchSizeChange = (newBatchSize: string) => {
|
||||
setBatchSize(newBatchSize);
|
||||
updateNode(nodeId, {
|
||||
options: {
|
||||
batchSize: newBatchSize ? parseInt(newBatchSize) : undefined,
|
||||
updateOnConflict,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateOnConflictChange = (checked: boolean) => {
|
||||
setUpdateOnConflict(checked);
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
conflictKeys,
|
||||
conflictKeyLabels,
|
||||
fieldMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
updateOnConflict,
|
||||
updateOnConflict: checked,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -476,7 +484,7 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
@@ -1145,13 +1153,6 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 적용 버튼 */}
|
||||
<div className="sticky bottom-0 border-t bg-white pt-3">
|
||||
<Button onClick={handleSave} className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
<p className="mt-2 text-center text-xs text-gray-500">✅ 변경 사항이 즉시 노드에 반영됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user