ERP-node/frontend/components/dataflow/external-call/FieldMappingEditor.tsx

401 lines
16 KiB
TypeScript
Raw Normal View History

2025-09-26 17:52:11 +09:00
"use client";
import React, { useState, useCallback } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Plus, Trash2, ArrowRight, Settings, Eye, EyeOff, RefreshCw, Database, Globe } from "lucide-react";
import {
FieldMapping,
TableInfo,
FieldInfo,
DataDirection,
DATA_TYPE_OPTIONS,
TRANSFORM_TYPE_OPTIONS,
} from "@/types/external-call/DataMappingTypes";
interface FieldMappingEditorProps {
mappings: FieldMapping[];
onMappingsChange: (mappings: FieldMapping[]) => void;
direction: "inbound" | "outbound";
sourceTable?: TableInfo; // outbound용
targetTable?: TableInfo; // inbound용
readonly?: boolean;
}
export const FieldMappingEditor: React.FC<FieldMappingEditorProps> = ({
mappings,
onMappingsChange,
direction,
sourceTable,
targetTable,
readonly = false,
}) => {
const [showAdvanced, setShowAdvanced] = useState(false);
const [sampleApiData, setSampleApiData] = useState("");
// 새 매핑 추가
const addMapping = useCallback(() => {
const newMapping: FieldMapping = {
id: `mapping-${Date.now()}`,
sourceField: "",
targetField: "",
dataType: "string",
required: false,
};
onMappingsChange([...mappings, newMapping]);
}, [mappings, onMappingsChange]);
// 매핑 삭제
const removeMapping = useCallback(
(id: string) => {
onMappingsChange(mappings.filter((m) => m.id !== id));
},
[mappings, onMappingsChange],
);
// 매핑 업데이트
const updateMapping = useCallback(
(id: string, updates: Partial<FieldMapping>) => {
onMappingsChange(mappings.map((m) => (m.id === id ? { ...m, ...updates } : m)));
},
[mappings, onMappingsChange],
);
// 자동 매핑 (이름 기반)
const autoMapFields = useCallback(() => {
const currentTable = direction === "inbound" ? targetTable : sourceTable;
if (!currentTable) return;
const newMappings: FieldMapping[] = [];
currentTable.fields.forEach((field) => {
// 이미 매핑된 필드는 건너뛰기
const existingMapping = mappings.find((m) =>
direction === "inbound" ? m.targetField === field.name : m.sourceField === field.name,
);
if (existingMapping) return;
const mapping: FieldMapping = {
id: `auto-${field.name}-${Date.now()}`,
sourceField: direction === "inbound" ? field.name : field.name,
targetField: direction === "inbound" ? field.name : field.name,
dataType: field.dataType,
required: !field.nullable,
};
newMappings.push(mapping);
});
onMappingsChange([...mappings, ...newMappings]);
}, [direction, targetTable, sourceTable, mappings, onMappingsChange]);
// 샘플 데이터에서 필드 추출
const extractFieldsFromSample = useCallback(() => {
if (!sampleApiData.trim()) return;
try {
const parsed = JSON.parse(sampleApiData);
const fields = Object.keys(parsed);
const newMappings: FieldMapping[] = [];
fields.forEach((fieldName) => {
// 이미 매핑된 필드는 건너뛰기
const existingMapping = mappings.find((m) =>
direction === "inbound" ? m.sourceField === fieldName : m.targetField === fieldName,
);
if (existingMapping) return;
// 데이터 타입 추론
const value = parsed[fieldName];
let dataType: any = "string";
if (typeof value === "number") dataType = "number";
else if (typeof value === "boolean") dataType = "boolean";
else if (value instanceof Date || /^\d{4}-\d{2}-\d{2}/.test(value)) dataType = "date";
const mapping: FieldMapping = {
id: `sample-${fieldName}-${Date.now()}`,
sourceField: direction === "inbound" ? fieldName : "",
targetField: direction === "inbound" ? "" : fieldName,
dataType,
required: false,
};
newMappings.push(mapping);
});
onMappingsChange([...mappings, ...newMappings]);
setSampleApiData("");
} catch (error) {
console.error("샘플 데이터 파싱 실패:", error);
}
}, [sampleApiData, direction, mappings, onMappingsChange]);
const currentTable = direction === "inbound" ? targetTable : sourceTable;
return (
<Card className="w-full">
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4" />
<Badge variant="outline">{mappings.length} </Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setShowAdvanced(!showAdvanced)}>
{showAdvanced ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{showAdvanced ? "간단히" : "고급"}
</Button>
{!readonly && (
<Button variant="outline" size="sm" onClick={autoMapFields} disabled={!currentTable}>
<RefreshCw className="h-3 w-3" />
</Button>
)}
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 샘플 데이터 입력 (고급 모드) */}
{showAdvanced && !readonly && (
<div className="bg-muted space-y-2 rounded-lg p-3">
<Label className="text-sm"> API (JSON)</Label>
<Textarea
value={sampleApiData}
onChange={(e) => setSampleApiData(e.target.value)}
placeholder='{"name": "홍길동", "age": 30, "email": "test@example.com"}'
rows={3}
/>
<Button size="sm" onClick={extractFieldsFromSample} disabled={!sampleApiData.trim()}>
</Button>
</div>
)}
{/* 매핑 목록 */}
<div className="space-y-3">
{mappings.map((mapping) => (
<Card key={mapping.id} className="p-3">
<div className="grid grid-cols-12 items-center gap-2">
{/* 소스 필드 */}
<div className="col-span-4">
<Label className="text-xs">{direction === "inbound" ? "외부 필드" : "내부 필드"}</Label>
<div className="mt-1 flex items-center gap-1">
{direction === "inbound" ? (
<Globe className="h-3 w-3 text-blue-500" />
) : (
<Database className="h-3 w-3 text-green-500" />
)}
<Input
value={mapping.sourceField}
onChange={(e) => updateMapping(mapping.id, { sourceField: e.target.value })}
placeholder={direction === "inbound" ? "API 필드명" : "테이블 컬럼명"}
size="sm"
disabled={readonly}
/>
</div>
</div>
{/* 화살표 */}
<div className="col-span-1 flex justify-center">
<ArrowRight className="text-muted-foreground h-4 w-4" />
</div>
{/* 타겟 필드 */}
<div className="col-span-4">
<Label className="text-xs">{direction === "inbound" ? "내부 필드" : "외부 필드"}</Label>
<div className="mt-1 flex items-center gap-1">
{direction === "inbound" ? (
<Database className="h-3 w-3 text-green-500" />
) : (
<Globe className="h-3 w-3 text-blue-500" />
)}
{direction === "inbound" && currentTable ? (
<Select
value={mapping.targetField}
onValueChange={(value) => {
const field = currentTable.fields.find((f) => f.name === value);
updateMapping(mapping.id, {
targetField: value,
dataType: field?.dataType || mapping.dataType,
});
}}
disabled={readonly}
>
<SelectTrigger size="sm">
<SelectValue placeholder="테이블 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{currentTable.fields.map((field) => (
<SelectItem key={field.name} value={field.name}>
<div className="flex items-center gap-2">
{field.name}
<Badge variant="outline" className="text-xs">
{field.dataType}
</Badge>
{field.isPrimaryKey && (
<Badge variant="default" className="text-xs">
PK
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={mapping.targetField}
onChange={(e) => updateMapping(mapping.id, { targetField: e.target.value })}
placeholder={direction === "inbound" ? "테이블 컬럼명" : "API 필드명"}
size="sm"
disabled={readonly}
/>
)}
</div>
</div>
{/* 데이터 타입 */}
<div className="col-span-2">
<Label className="text-xs"></Label>
<Select
value={mapping.dataType}
onValueChange={(value: any) => updateMapping(mapping.id, { dataType: value })}
disabled={readonly}
>
<SelectTrigger size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 삭제 버튼 */}
<div className="col-span-1">
{!readonly && (
<Button variant="ghost" size="sm" onClick={() => removeMapping(mapping.id)} className="h-8 w-8 p-0">
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
{/* 고급 설정 */}
{showAdvanced && (
<div className="mt-3 grid grid-cols-2 gap-3 border-t pt-3">
<div className="flex items-center space-x-2">
<Checkbox
id={`required-${mapping.id}`}
checked={mapping.required || false}
onCheckedChange={(checked) => updateMapping(mapping.id, { required: checked as boolean })}
disabled={readonly}
/>
<Label htmlFor={`required-${mapping.id}`} className="text-xs">
</Label>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={mapping.defaultValue || ""}
onChange={(e) => updateMapping(mapping.id, { defaultValue: e.target.value })}
placeholder="기본값"
size="sm"
disabled={readonly}
/>
</div>
{/* 변환 설정 */}
<div className="col-span-2 space-y-2">
<Label className="text-xs"> </Label>
<div className="grid grid-cols-3 gap-2">
<Select
value={mapping.transform?.type || "none"}
onValueChange={(value: any) =>
updateMapping(mapping.id, {
transform: { ...mapping.transform, type: value },
})
}
disabled={readonly}
>
<SelectTrigger size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRANSFORM_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{mapping.transform?.type === "constant" && (
<Input
value={mapping.transform.value || ""}
onChange={(e) =>
updateMapping(mapping.id, {
transform: { ...mapping.transform, value: e.target.value },
})
}
placeholder="상수값"
size="sm"
disabled={readonly}
/>
)}
{mapping.transform?.type === "format" && (
<Input
value={mapping.transform.format || ""}
onChange={(e) =>
updateMapping(mapping.id, {
transform: { ...mapping.transform, format: e.target.value },
})
}
placeholder="YYYY-MM-DD"
size="sm"
disabled={readonly}
/>
)}
</div>
</div>
</div>
)}
</Card>
))}
</div>
{/* 매핑 추가 버튼 */}
{!readonly && (
<Button variant="outline" onClick={addMapping} className="w-full">
<Plus className="mr-2 h-4 w-4" />
</Button>
)}
{/* 매핑 상태 */}
{mappings.length === 0 && (
<div className="text-muted-foreground py-6 text-center">
<Database className="mx-auto mb-2 h-8 w-8 opacity-50" />
<p className="text-sm"> .</p>
<p className="text-xs"> .</p>
</div>
)}
</CardContent>
</Card>
);
};