ERP-node/frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx

639 lines
24 KiB
TypeScript
Raw Normal View History

2025-10-08 09:39:13 +09:00
"use client";
/**
*
*/
import { useEffect, useState, useCallback } from "react";
import { Plus, Trash2, Search } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import type { ReferenceLookupNodeData } from "@/types/node-editor";
import { tableTypeApi } from "@/lib/api/screen";
// 필드 정의
interface FieldDefinition {
name: string;
label?: string;
type?: string;
}
interface ReferenceLookupPropertiesProps {
nodeId: string;
data: ReferenceLookupNodeData;
}
const OPERATORS = [
{ value: "=", label: "같음 (=)" },
{ value: "!=", label: "같지 않음 (≠)" },
{ value: ">", label: "보다 큼 (>)" },
{ value: "<", label: "보다 작음 (<)" },
{ value: ">=", label: "크거나 같음 (≥)" },
{ value: "<=", label: "작거나 같음 (≤)" },
{ value: "LIKE", label: "포함 (LIKE)" },
{ value: "IN", label: "IN" },
] as const;
export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPropertiesProps) {
const { updateNode, nodes, edges } = useFlowEditorStore();
// 상태
const [displayName, setDisplayName] = useState(data.displayName || "참조 조회");
const [referenceTable, setReferenceTable] = useState(data.referenceTable || "");
const [referenceTableLabel, setReferenceTableLabel] = useState(data.referenceTableLabel || "");
const [joinConditions, setJoinConditions] = useState(data.joinConditions || []);
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
const [outputFields, setOutputFields] = useState(data.outputFields || []);
// 소스 필드 수집
const [sourceFields, setSourceFields] = useState<FieldDefinition[]>([]);
// 참조 테이블 관련
const [tables, setTables] = useState<any[]>([]);
const [tablesLoading, setTablesLoading] = useState(false);
const [tablesOpen, setTablesOpen] = useState(false);
const [referenceColumns, setReferenceColumns] = useState<FieldDefinition[]>([]);
const [columnsLoading, setColumnsLoading] = useState(false);
// 데이터 변경 시 로컬 상태 동기화
useEffect(() => {
setDisplayName(data.displayName || "참조 조회");
setReferenceTable(data.referenceTable || "");
setReferenceTableLabel(data.referenceTableLabel || "");
setJoinConditions(data.joinConditions || []);
setWhereConditions(data.whereConditions || []);
setOutputFields(data.outputFields || []);
}, [data]);
// 🔍 소스 필드 수집 (업스트림 노드에서)
useEffect(() => {
const incomingEdges = edges.filter((e) => e.target === nodeId);
const fields: FieldDefinition[] = [];
for (const edge of incomingEdges) {
const sourceNode = nodes.find((n) => n.id === edge.source);
if (!sourceNode) continue;
const sourceData = sourceNode.data as any;
if (sourceNode.type === "tableSource" && sourceData.fields) {
fields.push(...sourceData.fields);
} else if (sourceNode.type === "externalDBSource" && sourceData.outputFields) {
fields.push(...sourceData.outputFields);
}
}
setSourceFields(fields);
}, [nodeId, nodes, edges]);
// 📊 테이블 목록 로드
useEffect(() => {
loadTables();
}, []);
const loadTables = async () => {
setTablesLoading(true);
try {
const data = await tableTypeApi.getTables();
setTables(data);
} catch (error) {
console.error("테이블 로드 실패:", error);
} finally {
setTablesLoading(false);
}
};
// 📋 참조 테이블 컬럼 로드
useEffect(() => {
if (referenceTable) {
loadReferenceColumns();
} else {
setReferenceColumns([]);
}
}, [referenceTable]);
const loadReferenceColumns = async () => {
if (!referenceTable) return;
setColumnsLoading(true);
try {
const cols = await tableTypeApi.getColumns(referenceTable);
const formatted = cols.map((col: any) => ({
name: col.columnName,
type: col.dataType,
label: col.displayName || col.columnName,
}));
setReferenceColumns(formatted);
} catch (error) {
console.error("컬럼 로드 실패:", error);
setReferenceColumns([]);
} finally {
setColumnsLoading(false);
}
};
// 테이블 선택 핸들러
const handleTableSelect = (tableName: string) => {
const selectedTable = tables.find((t) => t.tableName === tableName);
if (selectedTable) {
setReferenceTable(tableName);
setReferenceTableLabel(selectedTable.label);
setTablesOpen(false);
// 기존 설정 초기화
setJoinConditions([]);
setWhereConditions([]);
setOutputFields([]);
}
};
// 조인 조건 추가
const handleAddJoinCondition = () => {
setJoinConditions([
...joinConditions,
{
sourceField: "",
referenceField: "",
},
]);
};
const handleRemoveJoinCondition = (index: number) => {
setJoinConditions(joinConditions.filter((_, i) => i !== index));
};
const handleJoinConditionChange = (index: number, field: string, value: any) => {
const newConditions = [...joinConditions];
newConditions[index] = { ...newConditions[index], [field]: value };
// 라벨도 함께 저장
if (field === "sourceField") {
const sourceField = sourceFields.find((f) => f.name === value);
newConditions[index].sourceFieldLabel = sourceField?.label || value;
} else if (field === "referenceField") {
const refField = referenceColumns.find((f) => f.name === value);
newConditions[index].referenceFieldLabel = refField?.label || value;
}
setJoinConditions(newConditions);
};
// WHERE 조건 추가
const handleAddWhereCondition = () => {
setWhereConditions([
...whereConditions,
{
field: "",
operator: "=",
value: "",
valueType: "static",
},
]);
};
const handleRemoveWhereCondition = (index: number) => {
setWhereConditions(whereConditions.filter((_, i) => i !== index));
};
const handleWhereConditionChange = (index: number, field: string, value: any) => {
const newConditions = [...whereConditions];
newConditions[index] = { ...newConditions[index], [field]: value };
// 라벨도 함께 저장
if (field === "field") {
const refField = referenceColumns.find((f) => f.name === value);
newConditions[index].fieldLabel = refField?.label || value;
}
setWhereConditions(newConditions);
};
// 출력 필드 추가
const handleAddOutputField = () => {
setOutputFields([
...outputFields,
{
fieldName: "",
alias: "",
},
]);
};
const handleRemoveOutputField = (index: number) => {
setOutputFields(outputFields.filter((_, i) => i !== index));
};
const handleOutputFieldChange = (index: number, field: string, value: any) => {
const newFields = [...outputFields];
newFields[index] = { ...newFields[index], [field]: value };
// 라벨도 함께 저장
if (field === "fieldName") {
const refField = referenceColumns.find((f) => f.name === value);
newFields[index].fieldLabel = refField?.label || value;
// alias 자동 설정
if (!newFields[index].alias) {
newFields[index].alias = `ref_${value}`;
}
}
setOutputFields(newFields);
};
const handleSave = () => {
updateNode(nodeId, {
displayName,
referenceTable,
referenceTableLabel,
joinConditions,
whereConditions,
outputFields,
});
};
const selectedTableLabel = tables.find((t) => t.tableName === referenceTable)?.label || referenceTable;
return (
<ScrollArea className="h-full">
2025-10-24 14:11:12 +09:00
<div className="space-y-4 p-4 pb-8">
2025-10-08 09:39:13 +09:00
{/* 기본 정보 */}
<div>
<h3 className="mb-3 text-sm font-semibold"> </h3>
<div className="space-y-3">
<div>
<Label htmlFor="displayName" className="text-xs">
</Label>
<Input
id="displayName"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="mt-1"
placeholder="노드 표시 이름"
/>
</div>
{/* 참조 테이블 선택 */}
<div>
<Label className="text-xs"> </Label>
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tablesOpen}
className="mt-1 w-full justify-between"
disabled={tablesLoading}
>
{tablesLoading ? (
<span className="text-muted-foreground"> ...</span>
) : referenceTable ? (
<span className="truncate">{selectedTableLabel}</span>
) : (
<span className="text-muted-foreground"> ...</span>
)}
<Search className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-9" />
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
<ScrollArea className="h-[300px]">
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.label} ${table.tableName} ${table.description}`}
onSelect={() => handleTableSelect(table.tableName)}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
referenceTable === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.label}</span>
{table.label !== table.tableName && (
<span className="text-muted-foreground text-xs">{table.tableName}</span>
)}
</div>
</CommandItem>
))}
</ScrollArea>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
</div>
{/* 조인 조건 */}
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold"> (FK )</h3>
<Button
size="sm"
variant="outline"
onClick={handleAddJoinCondition}
className="h-7"
disabled={!referenceTable}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{joinConditions.length > 0 ? (
<div className="space-y-2">
{joinConditions.map((condition, index) => (
<div key={index} className="rounded border bg-purple-50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-purple-700"> #{index + 1}</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveJoinCondition(index)}
className="h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
<div>
<Label className="text-xs text-gray-600"> </Label>
<Select
value={condition.sourceField}
onValueChange={(value) => handleJoinConditionChange(index, "sourceField", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="소스 필드 선택" />
</SelectTrigger>
<SelectContent>
{sourceFields.map((field) => (
<SelectItem key={field.name} value={field.name}>
{field.label || field.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-gray-600"> </Label>
<Select
value={condition.referenceField}
onValueChange={(value) => handleJoinConditionChange(index, "referenceField", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="참조 필드 선택" />
</SelectTrigger>
<SelectContent>
{referenceColumns.map((field) => (
<SelectItem key={field.name} value={field.name}>
{field.label || field.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
))}
</div>
) : (
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
()
</div>
)}
</div>
{/* WHERE 조건 */}
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold">WHERE ()</h3>
<Button
size="sm"
variant="outline"
onClick={handleAddWhereCondition}
className="h-7"
disabled={!referenceTable}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{whereConditions.length > 0 && (
<div className="space-y-2">
{whereConditions.map((condition, index) => (
<div key={index} className="rounded border bg-yellow-50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-yellow-700">WHERE #{index + 1}</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveWhereCondition(index)}
className="h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
<div>
<Label className="text-xs text-gray-600"></Label>
<Select
value={condition.field}
onValueChange={(value) => handleWhereConditionChange(index, "field", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{referenceColumns.map((field) => (
<SelectItem key={field.name} value={field.name}>
{field.label || field.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-gray-600"></Label>
<Select
value={condition.operator}
onValueChange={(value) => handleWhereConditionChange(index, "operator", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-gray-600"> </Label>
<Select
value={condition.valueType || "static"}
onValueChange={(value) => handleWhereConditionChange(index, "valueType", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"></SelectItem>
<SelectItem value="field"> </SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-gray-600">
{condition.valueType === "field" ? "소스 필드" : "값"}
</Label>
{condition.valueType === "field" ? (
<Select
value={condition.value}
onValueChange={(value) => handleWhereConditionChange(index, "value", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="소스 필드 선택" />
</SelectTrigger>
<SelectContent>
{sourceFields.map((field) => (
<SelectItem key={field.name} value={field.name}>
{field.label || field.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={condition.value}
onChange={(e) => handleWhereConditionChange(index, "value", e.target.value)}
placeholder="비교할 값"
className="mt-1 h-8 text-xs"
/>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* 출력 필드 */}
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold"> </h3>
<Button
size="sm"
variant="outline"
onClick={handleAddOutputField}
className="h-7"
disabled={!referenceTable}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{outputFields.length > 0 ? (
<div className="space-y-2">
{outputFields.map((field, index) => (
<div key={index} className="rounded border bg-blue-50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-blue-700"> #{index + 1}</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveOutputField(index)}
className="h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
<div>
<Label className="text-xs text-gray-600"> </Label>
<Select
value={field.fieldName}
onValueChange={(value) => handleOutputFieldChange(index, "fieldName", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{referenceColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.label || col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-gray-600"> (Alias)</Label>
<Input
value={field.alias}
onChange={(e) => handleOutputFieldChange(index, "alias", e.target.value)}
placeholder="ref_field_name"
className="mt-1 h-8 text-xs"
/>
</div>
</div>
</div>
))}
</div>
) : (
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
()
</div>
)}
</div>
{/* 저장 버튼 */}
{/* 안내 */}
<div className="space-y-2">
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
🔗 <strong> </strong>: (: customer_id id)
</div>
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
<strong>WHERE </strong>: (: grade = 'VIP')
</div>
<div className="rounded bg-blue-50 p-3 text-xs text-blue-700">
📤 <strong> </strong>: ( )
</div>
</div>
</div>
</ScrollArea>
);
}