ERP-node/frontend/components/dataflow/connection/ColumnTableSection.tsx

343 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";
import { WebTypeInput } from "../condition/WebTypeInput";
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-destructive/10 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.displayName && column.displayName !== column.columnName
? column.displayName
: 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-destructive/10 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.displayName && column.displayName !== column.columnName
? column.displayName
: 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-primary"> {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">
<WebTypeInput
column={column}
value={mapping?.defaultValue || ""}
onChange={(value) => onDefaultValueChange(column.columnName, value)}
className="h-6 border-gray-200 text-xs focus:border-green-400 focus:ring-0"
placeholder="기본값 입력..."
tableName={tableName}
/>
</div>
)}
</div>
</div>
</div>
);
}
})}
</div>
{/* 하단 통계 */}
<div className="rounded-b-lg border border-gray-200 bg-gray-50 p-3 text-xs text-muted-foreground">
<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>
);
};