2025-09-26 17:52:11 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import React, { useState, useCallback, useEffect } from "react";
|
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
|
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
import { Plus, Trash2, Database, ArrowRight, Settings } from "lucide-react";
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
DataMappingConfig,
|
|
|
|
|
DataDirection,
|
|
|
|
|
TableInfo,
|
|
|
|
|
FieldMapping,
|
|
|
|
|
InboundMapping,
|
|
|
|
|
OutboundMapping,
|
|
|
|
|
DATA_DIRECTION_OPTIONS,
|
|
|
|
|
INSERT_MODE_OPTIONS,
|
|
|
|
|
} from "@/types/external-call/DataMappingTypes";
|
|
|
|
|
|
|
|
|
|
import { FieldMappingEditor } from "./FieldMappingEditor";
|
|
|
|
|
|
|
|
|
|
interface DataMappingSettingsProps {
|
|
|
|
|
config: DataMappingConfig;
|
|
|
|
|
onConfigChange: (config: DataMappingConfig) => void;
|
|
|
|
|
httpMethod: string;
|
|
|
|
|
availableTables?: TableInfo[];
|
|
|
|
|
readonly?: boolean;
|
2025-09-29 13:32:59 +09:00
|
|
|
tablesLoading?: boolean;
|
2025-09-26 17:52:11 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const DataMappingSettings: React.FC<DataMappingSettingsProps> = ({
|
|
|
|
|
config,
|
|
|
|
|
onConfigChange,
|
|
|
|
|
httpMethod,
|
|
|
|
|
availableTables = [],
|
|
|
|
|
readonly = false,
|
2025-09-29 13:32:59 +09:00
|
|
|
tablesLoading = false,
|
2025-09-26 17:52:11 +09:00
|
|
|
}) => {
|
|
|
|
|
const [localConfig, setLocalConfig] = useState<DataMappingConfig>(config);
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 변경 시 로컬 상태 동기화
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setLocalConfig(config);
|
|
|
|
|
}, [config]);
|
|
|
|
|
|
|
|
|
|
// HTTP 메서드에 따른 권장 방향 결정
|
|
|
|
|
const getRecommendedDirection = useCallback((method: string): DataDirection => {
|
|
|
|
|
const upperMethod = method.toUpperCase();
|
|
|
|
|
if (upperMethod === "GET") return "inbound";
|
|
|
|
|
if (["POST", "PUT", "PATCH"].includes(upperMethod)) return "outbound";
|
|
|
|
|
return "none";
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 방향 변경 핸들러
|
|
|
|
|
const handleDirectionChange = useCallback(
|
|
|
|
|
(direction: DataDirection) => {
|
|
|
|
|
const newConfig = {
|
|
|
|
|
...localConfig,
|
|
|
|
|
direction,
|
|
|
|
|
// 방향에 따라 불필요한 매핑 제거
|
|
|
|
|
inboundMapping:
|
|
|
|
|
direction === "inbound" || direction === "bidirectional"
|
|
|
|
|
? localConfig.inboundMapping || {
|
|
|
|
|
targetTable: "",
|
|
|
|
|
fieldMappings: [],
|
|
|
|
|
insertMode: "insert" as const,
|
|
|
|
|
}
|
|
|
|
|
: undefined,
|
|
|
|
|
outboundMapping:
|
|
|
|
|
direction === "outbound" || direction === "bidirectional"
|
|
|
|
|
? localConfig.outboundMapping || {
|
|
|
|
|
sourceTable: "",
|
|
|
|
|
fieldMappings: [],
|
|
|
|
|
}
|
|
|
|
|
: undefined,
|
|
|
|
|
};
|
|
|
|
|
setLocalConfig(newConfig);
|
|
|
|
|
onConfigChange(newConfig);
|
|
|
|
|
},
|
|
|
|
|
[localConfig, onConfigChange],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Inbound 매핑 업데이트
|
|
|
|
|
const handleInboundMappingChange = useCallback(
|
|
|
|
|
(mapping: Partial<InboundMapping>) => {
|
|
|
|
|
const newConfig = {
|
|
|
|
|
...localConfig,
|
|
|
|
|
inboundMapping: {
|
|
|
|
|
...localConfig.inboundMapping!,
|
|
|
|
|
...mapping,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
setLocalConfig(newConfig);
|
|
|
|
|
onConfigChange(newConfig);
|
|
|
|
|
},
|
|
|
|
|
[localConfig, onConfigChange],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Outbound 매핑 업데이트
|
|
|
|
|
const handleOutboundMappingChange = useCallback(
|
|
|
|
|
(mapping: Partial<OutboundMapping>) => {
|
|
|
|
|
const newConfig = {
|
|
|
|
|
...localConfig,
|
|
|
|
|
outboundMapping: {
|
|
|
|
|
...localConfig.outboundMapping!,
|
|
|
|
|
...mapping,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
setLocalConfig(newConfig);
|
|
|
|
|
onConfigChange(newConfig);
|
|
|
|
|
},
|
|
|
|
|
[localConfig, onConfigChange],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 필드 매핑 업데이트 (Inbound)
|
|
|
|
|
const handleInboundFieldMappingsChange = useCallback(
|
|
|
|
|
(fieldMappings: FieldMapping[]) => {
|
|
|
|
|
handleInboundMappingChange({ fieldMappings });
|
|
|
|
|
},
|
|
|
|
|
[handleInboundMappingChange],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 필드 매핑 업데이트 (Outbound)
|
|
|
|
|
const handleOutboundFieldMappingsChange = useCallback(
|
|
|
|
|
(fieldMappings: FieldMapping[]) => {
|
|
|
|
|
handleOutboundMappingChange({ fieldMappings });
|
|
|
|
|
},
|
|
|
|
|
[handleOutboundMappingChange],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 검증 함수
|
|
|
|
|
const isConfigValid = useCallback(() => {
|
|
|
|
|
if (localConfig.direction === "none") return true;
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
(localConfig.direction === "inbound" || localConfig.direction === "bidirectional") &&
|
|
|
|
|
localConfig.inboundMapping
|
|
|
|
|
) {
|
|
|
|
|
if (!localConfig.inboundMapping.targetTable) return false;
|
|
|
|
|
if (localConfig.inboundMapping.fieldMappings.length === 0) return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
(localConfig.direction === "outbound" || localConfig.direction === "bidirectional") &&
|
|
|
|
|
localConfig.outboundMapping
|
|
|
|
|
) {
|
|
|
|
|
if (!localConfig.outboundMapping.sourceTable) return false;
|
|
|
|
|
if (localConfig.outboundMapping.fieldMappings.length === 0) return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}, [localConfig]);
|
|
|
|
|
|
|
|
|
|
const recommendedDirection = getRecommendedDirection(httpMethod);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Card className="w-full">
|
|
|
|
|
<CardHeader className="pb-3">
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
<Database className="h-5 w-5" />
|
|
|
|
|
데이터 매핑 설정
|
|
|
|
|
{!isConfigValid() && <Badge variant="destructive">설정 필요</Badge>}
|
|
|
|
|
{isConfigValid() && localConfig.direction !== "none" && <Badge variant="default">설정 완료</Badge>}
|
|
|
|
|
</CardTitle>
|
|
|
|
|
<p className="text-muted-foreground text-sm">외부 API와 내부 테이블 간의 데이터 매핑을 설정합니다.</p>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
{/* 매핑 방향 선택 */}
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label>매핑 방향</Label>
|
|
|
|
|
<Select value={localConfig.direction} onValueChange={handleDirectionChange} disabled={readonly}>
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
<SelectValue placeholder="매핑 방향을 선택하세요" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{DATA_DIRECTION_OPTIONS.map((option) => (
|
|
|
|
|
<SelectItem key={option.value} value={option.value}>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{option.label}
|
|
|
|
|
{option.value === recommendedDirection && (
|
|
|
|
|
<Badge variant="secondary" className="text-xs">
|
|
|
|
|
권장
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
{localConfig.direction !== recommendedDirection && recommendedDirection !== "none" && (
|
|
|
|
|
<p className="text-xs text-amber-600">
|
|
|
|
|
💡 {httpMethod} 요청에는 "{DATA_DIRECTION_OPTIONS.find((o) => o.value === recommendedDirection)?.label}"
|
|
|
|
|
방향이 권장됩니다.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 매핑 설정 탭 */}
|
|
|
|
|
{localConfig.direction !== "none" && (
|
|
|
|
|
<Tabs
|
|
|
|
|
defaultValue={localConfig.direction === "bidirectional" ? "inbound" : localConfig.direction}
|
|
|
|
|
className="w-full"
|
|
|
|
|
>
|
|
|
|
|
<TabsList className="grid w-full grid-cols-2">
|
|
|
|
|
{(localConfig.direction === "inbound" || localConfig.direction === "bidirectional") && (
|
|
|
|
|
<TabsTrigger value="inbound">
|
|
|
|
|
<ArrowRight className="mr-1 h-4 w-4" />
|
|
|
|
|
외부 → 내부
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
)}
|
|
|
|
|
{(localConfig.direction === "outbound" || localConfig.direction === "bidirectional") && (
|
|
|
|
|
<TabsTrigger value="outbound">
|
|
|
|
|
<ArrowRight className="mr-1 h-4 w-4 rotate-180" />
|
|
|
|
|
내부 → 외부
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
)}
|
|
|
|
|
</TabsList>
|
|
|
|
|
|
|
|
|
|
{/* Inbound 매핑 설정 */}
|
|
|
|
|
{(localConfig.direction === "inbound" || localConfig.direction === "bidirectional") && (
|
|
|
|
|
<TabsContent value="inbound" className="space-y-4">
|
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label>대상 테이블</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={localConfig.inboundMapping?.targetTable || ""}
|
|
|
|
|
onValueChange={(value) => handleInboundMappingChange({ targetTable: value })}
|
2025-09-29 13:32:59 +09:00
|
|
|
disabled={readonly || tablesLoading}
|
2025-09-26 17:52:11 +09:00
|
|
|
>
|
|
|
|
|
<SelectTrigger>
|
2025-09-29 13:32:59 +09:00
|
|
|
<SelectValue placeholder={tablesLoading ? "테이블 목록 로딩 중..." : "저장할 테이블을 선택하세요"} />
|
2025-09-26 17:52:11 +09:00
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
2025-12-24 09:24:56 +09:00
|
|
|
{/* Radix UI Select v2.x: 빈 문자열 value="" 금지 → "__placeholder__" 사용 */}
|
2025-09-29 13:32:59 +09:00
|
|
|
{tablesLoading ? (
|
2025-12-24 09:24:56 +09:00
|
|
|
<SelectItem value="__placeholder__" disabled>
|
2025-09-29 13:32:59 +09:00
|
|
|
테이블 목록 로딩 중...
|
2025-09-26 17:52:11 +09:00
|
|
|
</SelectItem>
|
2025-09-29 13:32:59 +09:00
|
|
|
) : availableTables.length === 0 ? (
|
2025-12-24 09:24:56 +09:00
|
|
|
<SelectItem value="__placeholder__" disabled>
|
2025-09-29 13:32:59 +09:00
|
|
|
사용 가능한 테이블이 없습니다
|
|
|
|
|
</SelectItem>
|
|
|
|
|
) : (
|
|
|
|
|
availableTables.map((table) => (
|
|
|
|
|
<SelectItem key={table.name} value={table.name}>
|
|
|
|
|
{table.displayName || table.name}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))
|
|
|
|
|
)}
|
2025-09-26 17:52:11 +09:00
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label>저장 모드</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={localConfig.inboundMapping?.insertMode || "insert"}
|
|
|
|
|
onValueChange={(value) => handleInboundMappingChange({ insertMode: value as any })}
|
|
|
|
|
disabled={readonly}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{INSERT_MODE_OPTIONS.map((option) => (
|
|
|
|
|
<SelectItem key={option.value} value={option.value}>
|
|
|
|
|
{option.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 키 필드 설정 (upsert/update 모드일 때) */}
|
|
|
|
|
{localConfig.inboundMapping?.insertMode !== "insert" && (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label>키 필드</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={localConfig.inboundMapping?.keyFields?.join(", ") || ""}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
handleInboundMappingChange({
|
|
|
|
|
keyFields: e.target.value
|
|
|
|
|
.split(",")
|
|
|
|
|
.map((s) => s.trim())
|
|
|
|
|
.filter(Boolean),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
placeholder="id, code"
|
|
|
|
|
disabled={readonly}
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-muted-foreground text-xs">
|
|
|
|
|
업데이트/업서트 시 사용할 키 필드를 쉼표로 구분하여 입력하세요.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 필드 매핑 에디터 */}
|
|
|
|
|
{localConfig.inboundMapping?.targetTable && (
|
|
|
|
|
<FieldMappingEditor
|
|
|
|
|
mappings={localConfig.inboundMapping.fieldMappings}
|
|
|
|
|
onMappingsChange={handleInboundFieldMappingsChange}
|
|
|
|
|
direction="inbound"
|
|
|
|
|
targetTable={availableTables.find((t) => t.name === localConfig.inboundMapping?.targetTable)}
|
|
|
|
|
readonly={readonly}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</TabsContent>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Outbound 매핑 설정 */}
|
|
|
|
|
{(localConfig.direction === "outbound" || localConfig.direction === "bidirectional") && (
|
|
|
|
|
<TabsContent value="outbound" className="space-y-4">
|
|
|
|
|
<div className="grid grid-cols-1 gap-4">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label>소스 테이블</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={localConfig.outboundMapping?.sourceTable || ""}
|
|
|
|
|
onValueChange={(value) => handleOutboundMappingChange({ sourceTable: value })}
|
|
|
|
|
disabled={readonly}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
<SelectValue placeholder="데이터를 가져올 테이블을 선택하세요" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{availableTables.map((table) => (
|
|
|
|
|
<SelectItem key={table.name} value={table.name}>
|
|
|
|
|
{table.displayName || table.name}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 소스 필터 조건 */}
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label>필터 조건 (선택사항)</Label>
|
|
|
|
|
<Textarea
|
|
|
|
|
value={localConfig.outboundMapping?.sourceFilter || ""}
|
|
|
|
|
onChange={(e) => handleOutboundMappingChange({ sourceFilter: e.target.value })}
|
|
|
|
|
placeholder="status = 'active' AND created_at >= '2024-01-01'"
|
|
|
|
|
disabled={readonly}
|
|
|
|
|
rows={2}
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-muted-foreground text-xs">
|
|
|
|
|
WHERE 절에 사용할 조건을 입력하세요. 비어있으면 모든 데이터를 가져옵니다.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 필드 매핑 에디터 */}
|
|
|
|
|
{localConfig.outboundMapping?.sourceTable && (
|
|
|
|
|
<FieldMappingEditor
|
|
|
|
|
mappings={localConfig.outboundMapping.fieldMappings}
|
|
|
|
|
onMappingsChange={handleOutboundFieldMappingsChange}
|
|
|
|
|
direction="outbound"
|
|
|
|
|
sourceTable={availableTables.find((t) => t.name === localConfig.outboundMapping?.sourceTable)}
|
|
|
|
|
readonly={readonly}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</TabsContent>
|
|
|
|
|
)}
|
|
|
|
|
</Tabs>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 설정 요약 */}
|
|
|
|
|
{localConfig.direction !== "none" && (
|
|
|
|
|
<div className="bg-muted mt-4 rounded-lg p-3">
|
|
|
|
|
<h4 className="mb-2 text-sm font-medium">설정 요약</h4>
|
|
|
|
|
<div className="text-muted-foreground space-y-1 text-xs">
|
|
|
|
|
<div>방향: {DATA_DIRECTION_OPTIONS.find((o) => o.value === localConfig.direction)?.label}</div>
|
|
|
|
|
{localConfig.inboundMapping && (
|
|
|
|
|
<div>
|
|
|
|
|
외부 → {localConfig.inboundMapping.targetTable}({localConfig.inboundMapping.fieldMappings.length}개
|
|
|
|
|
필드)
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{localConfig.outboundMapping && (
|
|
|
|
|
<div>
|
|
|
|
|
{localConfig.outboundMapping.sourceTable} → 외부 ({localConfig.outboundMapping.fieldMappings.length}개
|
|
|
|
|
필드)
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
};
|