메일 관리 작업 저장용 커밋
This commit is contained in:
@@ -1,199 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Link, Loader2, CheckCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// API import
|
||||
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
|
||||
|
||||
// 타입 import
|
||||
import { Connection, TableInfo, ColumnInfo } from "@/lib/types/multiConnection";
|
||||
import { FieldMapping } from "../types/redesigned";
|
||||
|
||||
// 컴포넌트 import
|
||||
import FieldMappingCanvas from "./VisualMapping/FieldMappingCanvas";
|
||||
import React, { useState } from "react";
|
||||
import { ArrowLeft, Save, CheckCircle, XCircle, AlertCircle } from "lucide-react";
|
||||
import { TableInfo, FieldMapping, ColumnInfo } from "../types/redesigned";
|
||||
|
||||
interface FieldMappingStepProps {
|
||||
fromTable?: TableInfo;
|
||||
toTable?: TableInfo;
|
||||
fromConnection?: Connection;
|
||||
toConnection?: Connection;
|
||||
fieldMappings: FieldMapping[];
|
||||
onCreateMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
||||
onDeleteMapping: (mappingId: string) => void;
|
||||
onNext: () => void;
|
||||
onMappingsChange: (mappings: FieldMapping[]) => void;
|
||||
onBack: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 3단계: 시각적 필드 매핑
|
||||
* - SVG 기반 연결선 표시
|
||||
* - 드래그 앤 드롭 지원 (향후)
|
||||
* - 실시간 매핑 업데이트
|
||||
*/
|
||||
const FieldMappingStep: React.FC<FieldMappingStepProps> = ({
|
||||
export const FieldMappingStep: React.FC<FieldMappingStepProps> = ({
|
||||
fromTable,
|
||||
toTable,
|
||||
fromConnection,
|
||||
toConnection,
|
||||
fieldMappings,
|
||||
onCreateMapping,
|
||||
onDeleteMapping,
|
||||
onNext,
|
||||
onMappingsChange,
|
||||
onBack,
|
||||
onSave,
|
||||
}) => {
|
||||
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
|
||||
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [draggedField, setDraggedField] = useState<ColumnInfo | null>(null);
|
||||
|
||||
// 컬럼 정보 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
console.log("🔍 컬럼 로딩 시작:", {
|
||||
fromConnection: fromConnection?.id,
|
||||
toConnection: toConnection?.id,
|
||||
fromTable: fromTable?.tableName,
|
||||
toTable: toTable?.tableName,
|
||||
});
|
||||
|
||||
if (!fromConnection || !toConnection || !fromTable || !toTable) {
|
||||
console.warn("⚠️ 필수 정보 누락:", {
|
||||
fromConnection: !!fromConnection,
|
||||
toConnection: !!toConnection,
|
||||
fromTable: !!fromTable,
|
||||
toTable: !!toTable,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log("📡 API 호출 시작:", {
|
||||
fromAPI: `getColumnsFromConnection(${fromConnection.id}, "${fromTable.tableName}")`,
|
||||
toAPI: `getColumnsFromConnection(${toConnection.id}, "${toTable.tableName}")`,
|
||||
});
|
||||
|
||||
const [fromCols, toCols] = await Promise.all([
|
||||
getColumnsFromConnection(fromConnection.id, fromTable.tableName),
|
||||
getColumnsFromConnection(toConnection.id, toTable.tableName),
|
||||
]);
|
||||
|
||||
console.log("🔍 원본 API 응답 확인:", {
|
||||
fromCols: fromCols,
|
||||
toCols: toCols,
|
||||
fromType: typeof fromCols,
|
||||
toType: typeof toCols,
|
||||
fromIsArray: Array.isArray(fromCols),
|
||||
toIsArray: Array.isArray(toCols),
|
||||
});
|
||||
|
||||
// 안전한 배열 처리
|
||||
const safeFromCols = Array.isArray(fromCols) ? fromCols : [];
|
||||
const safeToCols = Array.isArray(toCols) ? toCols : [];
|
||||
|
||||
console.log("✅ 컬럼 로딩 성공:", {
|
||||
fromColumns: safeFromCols.length,
|
||||
toColumns: safeToCols.length,
|
||||
fromData: safeFromCols.slice(0, 2), // 처음 2개만 로깅
|
||||
toData: safeToCols.slice(0, 2),
|
||||
originalFromType: typeof fromCols,
|
||||
originalToType: typeof toCols,
|
||||
});
|
||||
|
||||
setFromColumns(safeFromCols);
|
||||
setToColumns(safeToCols);
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 정보 로드 실패:", error);
|
||||
toast.error("필드 정보를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
const createMapping = (fromField: ColumnInfo, toField: ColumnInfo) => {
|
||||
const mapping: FieldMapping = {
|
||||
id: `${fromField.name}-${toField.name}`,
|
||||
fromField,
|
||||
toField,
|
||||
isValid: fromField.type === toField.type,
|
||||
validationMessage: fromField.type !== toField.type ? "타입이 다릅니다" : undefined
|
||||
};
|
||||
|
||||
loadColumns();
|
||||
}, [fromConnection, toConnection, fromTable, toTable]);
|
||||
const newMappings = [...fieldMappings, mapping];
|
||||
onMappingsChange(newMappings);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<Loader2 className="mr-2 h-6 w-6 animate-spin" />
|
||||
<span>필드 정보를 불러오는 중...</span>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
const removeMapping = (mappingId: string) => {
|
||||
const newMappings = fieldMappings.filter(m => m.id !== mappingId);
|
||||
onMappingsChange(newMappings);
|
||||
};
|
||||
|
||||
const handleDragStart = (field: ColumnInfo) => {
|
||||
setDraggedField(field);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, toField: ColumnInfo) => {
|
||||
e.preventDefault();
|
||||
if (draggedField) {
|
||||
createMapping(draggedField, toField);
|
||||
setDraggedField(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getMappedFromField = (toFieldName: string) => {
|
||||
return fieldMappings.find(m => m.toField.name === toFieldName)?.fromField;
|
||||
};
|
||||
|
||||
const isFieldMapped = (fieldName: string) => {
|
||||
return fieldMappings.some(m => m.fromField.name === fieldName || m.toField.name === fieldName);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Link className="h-5 w-5" />
|
||||
3단계: 컬럼 매핑
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<div className="space-y-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
필드 매핑
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
소스 테이블의 필드를 대상 테이블의 필드에 드래그하여 매핑하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CardContent className="flex h-full flex-col p-0">
|
||||
{/* 매핑 캔버스 - 전체 영역 사용 */}
|
||||
<div className="min-h-0 flex-1 p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground">컬럼 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
) : fromColumns.length > 0 && toColumns.length > 0 ? (
|
||||
<FieldMappingCanvas
|
||||
fromFields={fromColumns}
|
||||
toFields={toColumns}
|
||||
mappings={fieldMappings}
|
||||
onCreateMapping={onCreateMapping}
|
||||
onDeleteMapping={onDeleteMapping}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-3">
|
||||
<div className="text-muted-foreground">컬럼 정보를 찾을 수 없습니다.</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
FROM 컬럼: {fromColumns.length}개, TO 컬럼: {toColumns.length}개
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
console.log("🔄 수동 재로딩 시도");
|
||||
setFromColumns([]);
|
||||
setToColumns([]);
|
||||
// useEffect가 재실행되도록 강제 업데이트
|
||||
setIsLoading(true);
|
||||
setTimeout(() => setIsLoading(false), 100);
|
||||
}}
|
||||
>
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* 매핑 통계 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-blue-50 p-4 rounded-lg border border-blue-200">
|
||||
<div className="text-2xl font-bold text-blue-900">{fieldMappings.length}</div>
|
||||
<div className="text-sm text-blue-700">총 매핑</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg border border-green-200">
|
||||
<div className="text-2xl font-bold text-green-900">
|
||||
{fieldMappings.filter(m => m.isValid).length}
|
||||
</div>
|
||||
<div className="text-sm text-green-700">유효한 매핑</div>
|
||||
</div>
|
||||
<div className="bg-red-50 p-4 rounded-lg border border-red-200">
|
||||
<div className="text-2xl font-bold text-red-900">
|
||||
{fieldMappings.filter(m => !m.isValid).length}
|
||||
</div>
|
||||
<div className="text-sm text-red-700">오류 매핑</div>
|
||||
</div>
|
||||
<div className="bg-yellow-50 p-4 rounded-lg border border-yellow-200">
|
||||
<div className="text-2xl font-bold text-yellow-900">
|
||||
{(toTable?.columns.length || 0) - fieldMappings.length}
|
||||
</div>
|
||||
<div className="text-sm text-yellow-700">미매핑 필드</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 네비게이션 - 고정 */}
|
||||
<div className="flex-shrink-0 border-t bg-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={onBack} className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
이전
|
||||
</Button>
|
||||
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{fieldMappings.length > 0 ? `${fieldMappings.length}개 매핑 완료` : "컬럼을 선택해서 매핑하세요"}
|
||||
{/* 매핑 영역 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* FROM 테이블 필드들 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-blue-600 font-bold text-sm">FROM</span>
|
||||
</div>
|
||||
|
||||
<Button onClick={onNext} disabled={fieldMappings.length === 0} className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
{fromTable?.name} 필드들
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
{fromTable?.columns.map((field) => (
|
||||
<div
|
||||
key={field.name}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(field)}
|
||||
className={`p-3 rounded-lg border-2 cursor-move transition-all duration-200 ${
|
||||
isFieldMapped(field.name)
|
||||
? "border-green-300 bg-green-50 opacity-60"
|
||||
: "border-blue-200 bg-blue-50 hover:border-blue-400 hover:bg-blue-100"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{field.name}</div>
|
||||
<div className="text-sm text-gray-600">{field.type}</div>
|
||||
{field.primaryKey && (
|
||||
<span className="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
|
||||
PK
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isFieldMapped(field.name) && (
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldMappingStep;
|
||||
{/* TO 테이블 필드들 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-green-600 font-bold text-sm">TO</span>
|
||||
</div>
|
||||
{toTable?.name} 필드들
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
{toTable?.columns.map((field) => {
|
||||
const mappedFromField = getMappedFromField(field.name);
|
||||
return (
|
||||
<div
|
||||
key={field.name}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, field)}
|
||||
className={`p-3 rounded-lg border-2 transition-all duration-200 ${
|
||||
mappedFromField
|
||||
? "border-green-300 bg-green-50"
|
||||
: "border-gray-200 bg-gray-50 hover:border-green-300 hover:bg-green-25"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{field.name}</div>
|
||||
<div className="text-sm text-gray-600">{field.type}</div>
|
||||
{field.primaryKey && (
|
||||
<span className="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
|
||||
PK
|
||||
</span>
|
||||
)}
|
||||
{mappedFromField && (
|
||||
<div className="text-xs text-green-700 mt-1">
|
||||
← {mappedFromField.name} ({mappedFromField.type})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{mappedFromField && (
|
||||
<button
|
||||
onClick={() => removeMapping(`${mappedFromField.name}-${field.name}`)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{mappedFromField && (
|
||||
<div>
|
||||
{fieldMappings.find(m => m.toField.name === field.name)?.isValid ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼들 */}
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 px-6 py-3 rounded-lg font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 transition-all duration-200"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
이전 단계
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="flex items-center gap-2 px-6 py-3 rounded-lg font-medium bg-orange-500 text-white hover:bg-orange-600 shadow-md hover:shadow-lg transition-all duration-200"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
연결 설정 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user