335 lines
14 KiB
TypeScript
335 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import React, { useMemo } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { ColumnInfo } from "@/lib/api/dataflow";
|
|
import { getInputTypeForDataType } from "@/utils/connectionUtils";
|
|
|
|
interface ColumnMapping {
|
|
toColumnName: string;
|
|
fromColumnName?: string;
|
|
defaultValue?: string;
|
|
}
|
|
|
|
interface ColumnTableSectionProps {
|
|
type: "from" | "to";
|
|
tableName: string;
|
|
columns: ColumnInfo[];
|
|
selectedColumn: string | null;
|
|
onColumnClick: (columnName: string) => void;
|
|
searchTerm: string;
|
|
onSearchChange: (term: string) => void;
|
|
dataTypeFilter: string;
|
|
onDataTypeFilterChange: (filter: string) => void;
|
|
showMappedOnly: boolean;
|
|
onShowMappedOnlyChange: (show: boolean) => void;
|
|
showUnmappedOnly: boolean;
|
|
onShowUnmappedOnlyChange: (show: boolean) => void;
|
|
columnMappings: ColumnMapping[];
|
|
onDefaultValueChange?: (columnName: string, value: string) => void;
|
|
onRemoveMapping?: (columnName: string) => void;
|
|
isColumnClickable: (column: ColumnInfo) => boolean;
|
|
oppositeSelectedColumn?: string | null;
|
|
oppositeColumns?: ColumnInfo[];
|
|
}
|
|
|
|
export const ColumnTableSection: React.FC<ColumnTableSectionProps> = ({
|
|
type,
|
|
tableName,
|
|
columns,
|
|
selectedColumn,
|
|
onColumnClick,
|
|
searchTerm,
|
|
onSearchChange,
|
|
dataTypeFilter,
|
|
onDataTypeFilterChange,
|
|
showMappedOnly,
|
|
onShowMappedOnlyChange,
|
|
showUnmappedOnly,
|
|
onShowUnmappedOnlyChange,
|
|
columnMappings,
|
|
onDefaultValueChange,
|
|
onRemoveMapping,
|
|
isColumnClickable,
|
|
oppositeSelectedColumn,
|
|
oppositeColumns,
|
|
}) => {
|
|
const isFromTable = type === "from";
|
|
|
|
// 데이터 타입 목록 추출
|
|
const dataTypes = useMemo(() => {
|
|
const types = new Set(columns.map((col) => col.dataType).filter((type): type is string => !!type));
|
|
return Array.from(types).sort();
|
|
}, [columns]);
|
|
|
|
// 필터링된 컬럼 목록
|
|
const filteredColumns = useMemo(() => {
|
|
return columns.filter((column) => {
|
|
// 검색어 필터
|
|
const matchesSearch = searchTerm === "" || column.columnName.toLowerCase().includes(searchTerm.toLowerCase());
|
|
|
|
// 데이터 타입 필터
|
|
const matchesDataType = dataTypeFilter === "" || column.dataType === dataTypeFilter;
|
|
|
|
// 매핑 상태 필터
|
|
const isMapped = isFromTable
|
|
? columnMappings.some((mapping) => mapping.fromColumnName === column.columnName)
|
|
: (() => {
|
|
const mapping = columnMappings.find((mapping) => mapping.toColumnName === column.columnName);
|
|
return !!mapping?.fromColumnName || !!(mapping?.defaultValue && mapping.defaultValue.trim());
|
|
})();
|
|
|
|
const matchesMappingFilter =
|
|
(!showMappedOnly && !showUnmappedOnly) || (showMappedOnly && isMapped) || (showUnmappedOnly && !isMapped);
|
|
|
|
return matchesSearch && matchesDataType && matchesMappingFilter;
|
|
});
|
|
}, [columns, searchTerm, dataTypeFilter, showMappedOnly, showUnmappedOnly, columnMappings, isFromTable]);
|
|
|
|
const mappedCount = columns.filter((column) =>
|
|
isFromTable
|
|
? columnMappings.some((mapping) => mapping.fromColumnName === column.columnName)
|
|
: (() => {
|
|
const mapping = columnMappings.find((mapping) => mapping.toColumnName === column.columnName);
|
|
return !!mapping?.fromColumnName || !!(mapping?.defaultValue && mapping.defaultValue.trim());
|
|
})(),
|
|
).length;
|
|
|
|
return (
|
|
<div className="flex-1">
|
|
{/* 헤더 */}
|
|
<div className="rounded-t-lg bg-gray-600 px-4 py-3 text-white">
|
|
<h3 className="text-sm font-semibold">
|
|
{isFromTable ? "From" : "To"}: {tableName} ({columns.length}/{columns.length})
|
|
</h3>
|
|
</div>
|
|
|
|
{/* 검색 및 필터 */}
|
|
<div className="border-r border-l border-gray-200 bg-gray-50 p-3">
|
|
<div className="flex flex-col gap-3">
|
|
<Input
|
|
placeholder="컬럼명 검색..."
|
|
value={searchTerm}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
className="h-8 text-xs"
|
|
/>
|
|
<div className="flex flex-col gap-2">
|
|
<Select
|
|
value={dataTypeFilter || "all"}
|
|
onValueChange={(value) => onDataTypeFilterChange(value === "all" ? "" : value || "")}
|
|
>
|
|
<SelectTrigger className="h-7 w-full text-xs" size="sm">
|
|
<SelectValue placeholder="모든 타입" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">모든 타입</SelectItem>
|
|
{dataTypes.map((type) => (
|
|
<SelectItem key={type} value={type}>
|
|
{type}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<div className="flex cursor-pointer gap-1">
|
|
<button
|
|
onClick={() => {
|
|
onShowMappedOnlyChange(!showMappedOnly);
|
|
if (showUnmappedOnly) onShowUnmappedOnlyChange(false);
|
|
}}
|
|
className={`rounded px-2 py-1 text-xs transition-colors hover:cursor-pointer ${
|
|
showMappedOnly ? "bg-gray-600 text-white" : "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
|
}`}
|
|
>
|
|
설정됨
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
onShowUnmappedOnlyChange(!showUnmappedOnly);
|
|
if (showMappedOnly) onShowMappedOnlyChange(false);
|
|
}}
|
|
className={`rounded px-2 py-1 text-xs transition-colors hover:cursor-pointer ${
|
|
showUnmappedOnly ? "bg-gray-600 text-white" : "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
|
}`}
|
|
>
|
|
미설정
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 컬럼 리스트 */}
|
|
<div className="h-96 overflow-y-auto border-r border-l border-gray-200">
|
|
{filteredColumns.map((column) => {
|
|
const isSelected = selectedColumn === column.columnName;
|
|
const isClickable = isColumnClickable(column);
|
|
|
|
if (isFromTable) {
|
|
// FROM 테이블 렌더링
|
|
const isMapped = columnMappings.some((mapping) => mapping.fromColumnName === column.columnName);
|
|
const mappedToColumn = columnMappings.find(
|
|
(mapping) => mapping.fromColumnName === column.columnName,
|
|
)?.toColumnName;
|
|
|
|
// 선택된 TO 컬럼과 타입 호환성 체크 (이미 매핑된 컬럼은 제외)
|
|
const isTypeCompatible =
|
|
!oppositeSelectedColumn ||
|
|
isMapped ||
|
|
oppositeColumns?.find((col) => col.columnName === oppositeSelectedColumn)?.dataType === column.dataType;
|
|
|
|
return (
|
|
<div
|
|
key={column.columnName}
|
|
onClick={isClickable ? () => onColumnClick(column.columnName) : undefined}
|
|
className={`border-b border-gray-200 px-3 py-2 text-xs transition-colors ${
|
|
isSelected
|
|
? "bg-gray-200 text-gray-800"
|
|
: isMapped
|
|
? "bg-gray-100 text-gray-700"
|
|
: oppositeSelectedColumn && !isTypeCompatible
|
|
? "cursor-not-allowed bg-red-50 text-red-400 opacity-60"
|
|
: isClickable
|
|
? "cursor-pointer hover:bg-gray-50"
|
|
: "cursor-not-allowed bg-gray-100 text-gray-400"
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="truncate font-medium">{column.columnName}</span>
|
|
{isSelected && <span className="flex-shrink-0 text-blue-500">●</span>}
|
|
{isMapped && <span className="flex-shrink-0 text-green-500">✓</span>}
|
|
{oppositeSelectedColumn && !isTypeCompatible && (
|
|
<span className="flex-shrink-0 text-red-500" title="데이터 타입이 호환되지 않음">
|
|
⚠
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div
|
|
className={`mt-1 text-xs ${oppositeSelectedColumn && !isTypeCompatible ? "text-red-400" : "text-gray-500"}`}
|
|
>
|
|
{column.dataType}
|
|
{oppositeSelectedColumn && !isTypeCompatible && (
|
|
<span className="ml-1 text-red-400">(호환 불가)</span>
|
|
)}
|
|
</div>
|
|
{isMapped && mappedToColumn && (
|
|
<div className="mt-1 truncate text-xs text-green-600">→ {mappedToColumn}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} else {
|
|
// TO 테이블 렌더링
|
|
const mapping = columnMappings.find((m) => m.toColumnName === column.columnName);
|
|
const isMapped = !!mapping?.fromColumnName;
|
|
const hasDefaultValue = !!(mapping?.defaultValue && mapping.defaultValue.trim());
|
|
|
|
// 선택된 FROM 컬럼과 타입 호환성 체크 (이미 매핑된 컬럼은 제외)
|
|
const isTypeCompatible =
|
|
!oppositeSelectedColumn ||
|
|
isMapped ||
|
|
oppositeColumns?.find((col) => col.columnName === oppositeSelectedColumn)?.dataType === column.dataType;
|
|
|
|
return (
|
|
<div
|
|
key={column.columnName}
|
|
className={`border-b border-gray-200 transition-colors ${
|
|
isSelected
|
|
? "bg-gray-200"
|
|
: isMapped
|
|
? "bg-gray-100"
|
|
: hasDefaultValue
|
|
? "bg-gray-100"
|
|
: oppositeSelectedColumn && !isTypeCompatible
|
|
? "bg-red-50 opacity-60"
|
|
: "bg-white"
|
|
}`}
|
|
>
|
|
{/* 컬럼 정보 행 */}
|
|
<div
|
|
onClick={isClickable && isTypeCompatible ? () => onColumnClick(column.columnName) : undefined}
|
|
className={`px-3 py-2 text-xs ${
|
|
isClickable && isTypeCompatible
|
|
? "cursor-pointer hover:bg-gray-50"
|
|
: oppositeSelectedColumn && !isTypeCompatible
|
|
? "cursor-not-allowed"
|
|
: hasDefaultValue
|
|
? "cursor-not-allowed"
|
|
: ""
|
|
}`}
|
|
>
|
|
<div className="flex min-w-0 flex-col justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="truncate font-medium">{column.columnName}</span>
|
|
{isSelected && <span className="flex-shrink-0 text-green-500">●</span>}
|
|
{oppositeSelectedColumn && !isTypeCompatible && (
|
|
<span className="flex-shrink-0 text-red-500" title="데이터 타입이 호환되지 않음">
|
|
⚠
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div
|
|
className={`mt-1 text-xs ${oppositeSelectedColumn && !isTypeCompatible ? "text-red-400" : "text-gray-500"}`}
|
|
>
|
|
{column.dataType}
|
|
{oppositeSelectedColumn && !isTypeCompatible && (
|
|
<span className="ml-1 text-red-400">(호환 불가)</span>
|
|
)}
|
|
</div>
|
|
|
|
{isMapped && (
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<span className="truncate text-xs text-blue-600">← {mapping.fromColumnName}</span>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRemoveMapping?.(column.columnName);
|
|
}}
|
|
className="flex-shrink-0 text-red-500 hover:text-red-700"
|
|
title="매핑 제거"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{!isMapped && onDefaultValueChange && (
|
|
<div className="mt-2">
|
|
<Input
|
|
type={getInputTypeForDataType(column.dataType?.toLowerCase() || "string")}
|
|
placeholder="기본값 입력..."
|
|
value={mapping?.defaultValue || ""}
|
|
onChange={(e) => onDefaultValueChange(column.columnName, e.target.value)}
|
|
className="h-6 border-gray-200 text-xs focus:border-green-400 focus:ring-0"
|
|
onClick={(e) => e.stopPropagation()}
|
|
disabled={isSelected || !!oppositeSelectedColumn}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
})}
|
|
</div>
|
|
|
|
{/* 하단 통계 */}
|
|
<div className="rounded-b-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
|
|
<div className="flex items-center justify-between">
|
|
<span>
|
|
{isFromTable ? "매핑됨" : "설정됨"}: {mappedCount}/{columns.length}
|
|
</span>
|
|
<Badge variant="secondary" className="text-xs">
|
|
표시: {filteredColumns.length}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|