ERP-node/frontend/components/flow/FlowStepPanel.tsx

889 lines
37 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 플로우 단계 설정 패널
* 선택된 단계의 속성 편집
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { X, Trash2, Save, Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useToast } from "@/hooks/use-toast";
import { updateFlowStep, deleteFlowStep } from "@/lib/api/flow";
import { FlowStep } from "@/types/flow";
import { FlowConditionBuilder } from "./FlowConditionBuilder";
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
import { cn } from "@/lib/utils";
import { flowExternalDbApi } from "@/lib/api/flowExternalDb";
import {
FlowExternalDbConnection,
FlowExternalDbIntegrationConfig,
INTEGRATION_TYPE_OPTIONS,
OPERATION_OPTIONS,
} from "@/types/flowExternalDb";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
interface FlowStepPanelProps {
step: FlowStep;
flowId: number;
onClose: () => void;
onUpdate: () => void;
}
export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanelProps) {
const { toast } = useToast();
const [formData, setFormData] = useState({
stepName: step.stepName,
tableName: step.tableName || "",
conditionJson: step.conditionJson,
// 하이브리드 모드 필드
moveType: step.moveType || "status",
statusColumn: step.statusColumn || "",
statusValue: step.statusValue || "",
targetTable: step.targetTable || "",
fieldMappings: step.fieldMappings || {},
// 외부 연동 필드
integrationType: step.integrationType || "internal",
integrationConfig: step.integrationConfig,
});
const [tableList, setTableList] = useState<any[]>([]);
const [loadingTables, setLoadingTables] = useState(true);
const [openTableCombobox, setOpenTableCombobox] = useState(false);
// 외부 DB 테이블 목록
const [externalTableList, setExternalTableList] = useState<string[]>([]);
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID
// 컬럼 목록 (상태 컬럼 선택용)
const [columns, setColumns] = useState<any[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [openStatusColumnCombobox, setOpenStatusColumnCombobox] = useState(false);
// 외부 DB 연결 목록
const [externalConnections, setExternalConnections] = useState<FlowExternalDbConnection[]>([]);
const [loadingConnections, setLoadingConnections] = useState(false);
// 테이블 목록 조회
useEffect(() => {
const loadTables = async () => {
try {
setLoadingTables(true);
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setTableList(response.data);
}
} catch (error) {
console.error("Failed to load tables:", error);
} finally {
setLoadingTables(false);
}
};
loadTables();
}, []);
// 외부 DB 연결 목록 조회 (JWT 토큰 사용)
useEffect(() => {
const loadConnections = async () => {
try {
setLoadingConnections(true);
// localStorage에서 JWT 토큰 가져오기
const token = localStorage.getItem("authToken");
if (!token) {
console.warn("토큰이 없습니다. 외부 DB 연결 목록을 조회할 수 없습니다.");
setExternalConnections([]);
return;
}
const response = await fetch("/api/external-db-connections/control/active", {
credentials: "include",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}).catch((err) => {
console.warn("외부 DB 연결 목록 fetch 실패:", err);
return null;
});
if (response && response.ok) {
const result = await response.json();
if (result.success && result.data) {
// 메인 DB 제외하고 외부 DB만 필터링
const externalOnly = result.data.filter((conn: any) => conn.id !== 0);
setExternalConnections(externalOnly);
} else {
setExternalConnections([]);
}
} else {
// 401 오류 시 빈 배열로 처리 (리다이렉트 방지)
console.warn("외부 DB 연결 목록 조회 실패:", response?.status || "네트워크 오류");
setExternalConnections([]);
}
} catch (error: any) {
console.error("Failed to load external connections:", error);
setExternalConnections([]);
} finally {
setLoadingConnections(false);
}
};
loadConnections();
}, []);
// 외부 DB 선택 시 해당 DB의 테이블 목록 조회 (JWT 토큰 사용)
useEffect(() => {
const loadExternalTables = async () => {
console.log("🔍 loadExternalTables triggered, selectedDbSource:", selectedDbSource);
if (selectedDbSource === "internal" || typeof selectedDbSource !== "number") {
console.log("⚠️ Skipping external table load (internal or not a number)");
setExternalTableList([]);
return;
}
console.log("📡 Loading external tables for connection ID:", selectedDbSource);
try {
setLoadingExternalTables(true);
// localStorage에서 JWT 토큰 가져오기
const token = localStorage.getItem("authToken");
if (!token) {
console.warn("토큰이 없습니다. 외부 DB 테이블 목록을 조회할 수 없습니다.");
setExternalTableList([]);
return;
}
// 기존 multi-connection API 사용 (JWT 토큰 포함)
const response = await fetch(`/api/multi-connection/connections/${selectedDbSource}/tables`, {
credentials: "include",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}).catch((err) => {
console.warn("외부 DB 테이블 목록 fetch 실패:", err);
return null;
});
if (response && response.ok) {
const result = await response.json();
console.log("✅ External tables API response:", result);
console.log("📊 result.data type:", typeof result.data, "isArray:", Array.isArray(result.data));
console.log("📊 result.data:", JSON.stringify(result.data, null, 2));
if (result.success && result.data) {
// 데이터 형식이 다를 수 있으므로 변환
const tableNames = result.data.map((t: any) => {
console.log("🔍 Processing item:", t, "type:", typeof t);
// tableName (camelCase), table_name, tablename, name 모두 지원
return typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name;
});
console.log("📋 Processed table names:", tableNames);
setExternalTableList(tableNames);
} else {
console.warn("❌ No data in response or success=false");
setExternalTableList([]);
}
} else {
// 인증 오류 시에도 빈 배열로 처리 (리다이렉트 방지)
console.warn(`외부 DB 테이블 목록 조회 실패: ${response?.status || "네트워크 오류"}`);
setExternalTableList([]);
}
} catch (error) {
console.error("외부 DB 테이블 목록 조회 오류:", error);
setExternalTableList([]);
} finally {
setLoadingExternalTables(false);
}
};
loadExternalTables();
}, [selectedDbSource]);
useEffect(() => {
console.log("🔄 Initializing formData from step:", {
id: step.id,
stepName: step.stepName,
statusColumn: step.statusColumn,
statusValue: step.statusValue,
});
const newFormData = {
stepName: step.stepName,
tableName: step.tableName || "",
conditionJson: step.conditionJson,
// 하이브리드 모드 필드
moveType: step.moveType || "status",
statusColumn: step.statusColumn || "",
statusValue: step.statusValue || "",
targetTable: step.targetTable || "",
fieldMappings: step.fieldMappings || {},
// 외부 연동 필드
integrationType: step.integrationType || "internal",
integrationConfig: step.integrationConfig,
};
console.log("✅ Setting formData:", newFormData);
setFormData(newFormData);
}, [step.id]); // step 전체가 아닌 step.id만 의존성으로 설정
// 테이블 선택 시 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!formData.tableName) {
setColumns([]);
return;
}
try {
setLoadingColumns(true);
console.log("🔍 Loading columns for status column selector:", formData.tableName);
const response = await getTableColumns(formData.tableName);
console.log("📦 Columns response:", response);
if (response.success && response.data && response.data.columns) {
console.log("✅ Setting columns:", response.data.columns);
setColumns(response.data.columns);
} else {
console.log("❌ No columns in response");
setColumns([]);
}
} catch (error) {
console.error("Failed to load columns:", error);
setColumns([]);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [formData.tableName]);
// formData의 최신 값을 항상 참조하기 위한 ref
const formDataRef = useRef(formData);
// formData가 변경될 때마다 ref 업데이트
useEffect(() => {
formDataRef.current = formData;
}, [formData]);
// 저장
const handleSave = useCallback(async () => {
const currentFormData = formDataRef.current;
console.log("🚀 handleSave called, formData:", JSON.stringify(currentFormData, null, 2));
try {
const response = await updateFlowStep(step.id, currentFormData);
console.log("📡 API response:", response);
if (response.success) {
toast({
title: "저장 완료",
description: "단계가 수정되었습니다.",
});
onUpdate();
onClose();
} else {
toast({
title: "저장 실패",
description: response.error,
variant: "destructive",
});
}
} catch (error: any) {
toast({
title: "오류 발생",
description: error.message,
variant: "destructive",
});
}
}, [step.id, onUpdate, onClose, toast]);
// 삭제
const handleDelete = async () => {
if (!confirm(`"${step.stepName}" 단계를 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`)) {
return;
}
try {
const response = await deleteFlowStep(step.id);
if (response.success) {
toast({
title: "삭제 완료",
description: "단계가 삭제되었습니다.",
});
onUpdate();
onClose();
} else {
toast({
title: "삭제 실패",
description: response.error,
variant: "destructive",
});
}
} catch (error: any) {
toast({
title: "오류 발생",
description: error.message,
variant: "destructive",
});
}
};
return (
<div className="fixed top-0 right-0 z-50 h-full w-96 overflow-y-auto border-l bg-white shadow-xl">
<div className="space-y-6 p-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold"> </h2>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label> </Label>
<Input
value={formData.stepName}
onChange={(e) => setFormData({ ...formData, stepName: e.target.value })}
placeholder="단계 이름 입력"
/>
</div>
<div>
<Label> </Label>
<Input value={step.stepOrder} disabled />
</div>
{/* DB 소스 선택 */}
<div>
<Label> </Label>
<Select
value={selectedDbSource.toString()}
onValueChange={(value) => {
const dbSource = value === "internal" ? "internal" : parseInt(value);
setSelectedDbSource(dbSource);
// DB 소스 변경 시 테이블 선택 초기화
setFormData({ ...formData, tableName: "" });
}}
>
<SelectTrigger>
<SelectValue placeholder="데이터베이스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal"> </SelectItem>
{externalConnections.map((conn: any) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
{conn.connection_name} ({conn.db_type?.toUpperCase()})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
{/* 테이블 선택 */}
<div>
<Label> </Label>
<Popover open={openTableCombobox} onOpenChange={setOpenTableCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openTableCombobox}
className="w-full justify-between"
disabled={loadingTables || (selectedDbSource !== "internal" && loadingExternalTables)}
>
{formData.tableName
? selectedDbSource === "internal"
? tableList.find((table) => table.tableName === formData.tableName)?.displayName ||
formData.tableName
: formData.tableName
: loadingTables || loadingExternalTables
? "로딩 중..."
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." />
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{selectedDbSource === "internal"
? // 내부 DB 테이블 목록
tableList.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={(currentValue) => {
setFormData({ ...formData, tableName: currentValue });
setOpenTableCombobox(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.tableName === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.displayName || table.tableName}</span>
{table.description && (
<span className="text-xs text-gray-500">{table.description}</span>
)}
</div>
</CommandItem>
))
: // 외부 DB 테이블 목록 (문자열 배열)
externalTableList.map((tableName, index) => (
<CommandItem
key={`external-${selectedDbSource}-${tableName}-${index}`}
value={tableName}
onSelect={(currentValue) => {
setFormData({ ...formData, tableName: currentValue });
setOpenTableCombobox(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.tableName === tableName ? "opacity-100" : "opacity-0",
)}
/>
<div>{tableName}</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="mt-1 text-xs text-gray-500">
{selectedDbSource === "internal"
? "이 단계에서 조건을 적용할 테이블을 선택합니다"
: "외부 데이터베이스의 테이블을 선택합니다"}
</p>
</div>
</CardContent>
</Card>
{/* 조건 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
{!formData.tableName ? (
<div className="py-8 text-center text-gray-500"> </div>
) : (
<FlowConditionBuilder
flowId={flowId}
tableName={formData.tableName}
condition={formData.conditionJson}
onChange={(condition) => setFormData({ ...formData, conditionJson: condition })}
/>
)}
</CardContent>
</Card>
{/* 데이터 이동 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 이동 방식 선택 */}
<div>
<Label> </Label>
<Select
value={formData.moveType}
onValueChange={(value: "status" | "table" | "both") => setFormData({ ...formData, moveType: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="status">
<div>
<div className="font-medium"> </div>
<div className="text-xs text-gray-500"> </div>
</div>
</SelectItem>
<SelectItem value="table">
<div>
<div className="font-medium"> </div>
<div className="text-xs text-gray-500"> </div>
</div>
</SelectItem>
<SelectItem value="both">
<div>
<div className="font-medium"></div>
<div className="text-xs text-gray-500"> + </div>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 상태 변경 설정 (status 또는 both일 때) */}
{(formData.moveType === "status" || formData.moveType === "both") && (
<>
<div>
<Label> </Label>
<Popover open={openStatusColumnCombobox} onOpenChange={setOpenStatusColumnCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openStatusColumnCombobox}
className="w-full justify-between"
disabled={!formData.tableName || loadingColumns}
>
{loadingColumns
? "컬럼 로딩 중..."
: formData.statusColumn
? columns.find((col) => col.columnName === formData.statusColumn)?.columnName ||
formData.statusColumn
: "상태 컬럼 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." />
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{columns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={() => {
setFormData({ ...formData, statusColumn: column.columnName });
setOpenStatusColumnCombobox(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.statusColumn === column.columnName ? "opacity-100" : "opacity-0",
)}
/>
<div>
<div>{column.columnName}</div>
<div className="text-xs text-gray-500">({column.dataType})</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
<div>
<Label> </Label>
<Input
value={formData.statusValue}
onChange={(e) => {
const newValue = e.target.value;
console.log("💡 statusValue onChange:", newValue);
setFormData({ ...formData, statusValue: newValue });
console.log("✅ Updated formData:", { ...formData, statusValue: newValue });
}}
placeholder="예: approved"
/>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
</>
)}
{/* 테이블 이동 설정 (table 또는 both일 때) */}
{(formData.moveType === "table" || formData.moveType === "both") && (
<>
<div>
<Label> </Label>
<Select
value={formData.targetTable}
onValueChange={(value) => setFormData({ ...formData, targetTable: value })}
>
<SelectTrigger>
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tableList.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
<div className="rounded-md bg-blue-50 p-3">
<p className="text-sm text-blue-900">
💡 . .
</p>
</div>
</>
)}
</CardContent>
</Card>
{/* 외부 DB 연동 설정 */}
<Card>
<CardHeader>
<CardTitle> DB </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label> </Label>
<Select
value={formData.integrationType}
onValueChange={(value: any) => {
setFormData({ ...formData, integrationType: value });
// 타입 변경 시 config 초기화
if (value === "internal") {
setFormData((prev) => ({ ...prev, integrationConfig: undefined }));
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INTEGRATION_TYPE_OPTIONS.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.value !== "internal" && opt.value !== "external_db"}
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 외부 DB 연동 설정 */}
{formData.integrationType === "external_db" && (
<div className="space-y-4 rounded-lg border p-4">
{externalConnections.length === 0 ? (
<div className="rounded-md bg-yellow-50 p-3">
<p className="text-sm text-yellow-900">
DB . DB .
</p>
</div>
) : (
<>
<div>
<Label> DB </Label>
<Select
value={formData.integrationConfig?.connectionId?.toString() || ""}
onValueChange={(value) => {
const connectionId = parseInt(value);
setFormData({
...formData,
integrationConfig: {
type: "external_db",
connectionId,
operation: "update",
tableName: "",
updateFields: {},
whereCondition: {},
},
});
}}
>
<SelectTrigger>
<SelectValue placeholder="연결 선택" />
</SelectTrigger>
<SelectContent>
{externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
{conn.name} ({conn.dbType})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{formData.integrationConfig?.connectionId && (
<>
<div>
<Label> </Label>
<Select
value={formData.integrationConfig.operation}
onValueChange={(value: any) =>
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
operation: value,
},
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<Input
value={formData.integrationConfig.tableName}
onChange={(e) =>
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
tableName: e.target.value,
},
})
}
placeholder="예: orders"
/>
</div>
{formData.integrationConfig.operation === "custom" ? (
<div>
<Label> </Label>
<Textarea
value={formData.integrationConfig.customQuery || ""}
onChange={(e) =>
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
customQuery: e.target.value,
},
})
}
placeholder="UPDATE orders SET status = 'approved' WHERE id = {{dataId}}"
rows={4}
className="font-mono text-sm"
/>
<p className="mt-1 text-xs text-gray-500">
릿 : {`{{dataId}}, {{currentUser}}, {{currentTimestamp}}`}
</p>
</div>
) : (
<>
{(formData.integrationConfig.operation === "update" ||
formData.integrationConfig.operation === "insert") && (
<div>
<Label> (JSON)</Label>
<Textarea
value={JSON.stringify(formData.integrationConfig.updateFields || {}, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
updateFields: parsed,
},
});
} catch (err) {
// JSON 파싱 실패 시 무시
}
}}
placeholder='{"status": "approved", "updated_by": "{{currentUser}}"}'
rows={4}
className="font-mono text-sm"
/>
</div>
)}
{(formData.integrationConfig.operation === "update" ||
formData.integrationConfig.operation === "delete") && (
<div>
<Label>WHERE (JSON)</Label>
<Textarea
value={JSON.stringify(formData.integrationConfig.whereCondition || {}, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
whereCondition: parsed,
},
});
} catch (err) {
// JSON 파싱 실패 시 무시
}
}}
placeholder='{"id": "{{dataId}}"}'
rows={3}
className="font-mono text-sm"
/>
</div>
)}
</>
)}
<div className="rounded-md bg-blue-50 p-3">
<p className="text-sm text-blue-900">
💡 릿 :
<br /> {`{{dataId}}`} - ID
<br /> {`{{currentUser}}`} -
<br /> {`{{currentTimestamp}}`} -
</p>
</div>
</>
)}
</>
)}
</div>
)}
</CardContent>
</Card>
{/* 액션 버튼 */}
<div className="flex gap-2">
<Button className="flex-1" onClick={handleSave}>
<Save className="mr-2 h-4 w-4" />
</Button>
<Button variant="destructive" onClick={handleDelete}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}