247 lines
9.2 KiB
TypeScript
247 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* TableColumnPalette — 테이블 데이터 연결 탭의 컬럼 팔레트
|
|
*
|
|
* 2단계 플로우:
|
|
* 1. 체크박스로 사용할 컬럼을 중복 선택
|
|
* 2. 선택된 컬럼만 드래그 가능한 칩으로 표시 → 드롭 존에 배치
|
|
*/
|
|
|
|
import React, { useState, useMemo, useEffect } from "react";
|
|
import { useDrag } from "react-dnd";
|
|
import { Columns, Loader2, ChevronDown, ChevronUp, X } from "lucide-react";
|
|
|
|
export const TABLE_COLUMN_DND_TYPE = "table-column";
|
|
|
|
export interface SchemaColumn {
|
|
column_name: string;
|
|
data_type: string;
|
|
is_nullable: string;
|
|
}
|
|
|
|
// ─── 드래그 가능한 선택된 컬럼 칩 ────────────────────────────────────────────
|
|
|
|
interface DraggableColumnProps {
|
|
column: SchemaColumn;
|
|
placed?: boolean;
|
|
onRemove?: () => void;
|
|
}
|
|
|
|
function DraggableColumn({ column, placed = false, onRemove }: DraggableColumnProps) {
|
|
const [{ isDragging }, drag] = useDrag(() => ({
|
|
type: TABLE_COLUMN_DND_TYPE,
|
|
item: { columnName: column.column_name, dataType: column.data_type },
|
|
canDrag: () => !placed,
|
|
collect: (monitor) => ({
|
|
isDragging: monitor.isDragging(),
|
|
}),
|
|
}), [column, placed]);
|
|
|
|
return (
|
|
<div
|
|
ref={placed ? undefined : drag}
|
|
className={`relative flex items-center gap-1.5 rounded-lg border px-3 py-2 transition-colors ${
|
|
placed
|
|
? "cursor-default border-gray-200 bg-gray-100 opacity-50"
|
|
: `cursor-move border-blue-200 bg-blue-50 hover:border-blue-400 hover:bg-blue-100 ${isDragging ? "opacity-50 shadow-lg" : ""}`
|
|
}`}
|
|
>
|
|
<Columns className={`h-3.5 w-3.5 shrink-0 ${placed ? "text-gray-400" : "text-blue-500"}`} />
|
|
<span className={`truncate font-mono text-xs font-medium ${placed ? "text-gray-400 line-through" : "text-blue-700"}`}>
|
|
{column.column_name}
|
|
</span>
|
|
{placed ? (
|
|
<span className="shrink-0 rounded bg-green-100 px-1 py-0.5 text-[9px] text-green-600">배치됨</span>
|
|
) : (
|
|
<span className="shrink-0 rounded bg-blue-100 px-1 py-0.5 text-[9px] text-blue-500">
|
|
{column.data_type}
|
|
</span>
|
|
)}
|
|
{onRemove && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
|
className="absolute -right-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-gray-400 text-white shadow-sm hover:bg-red-500"
|
|
>
|
|
<X className="h-2.5 w-2.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 메인 팔레트 ──────────────────────────────────────────────────────────────
|
|
|
|
interface TableColumnPaletteProps {
|
|
columns: SchemaColumn[];
|
|
loading?: boolean;
|
|
maxSelectable?: number;
|
|
placedColumns?: Set<string>;
|
|
onColumnRemove?: (columnName: string) => void;
|
|
}
|
|
|
|
export function TableColumnPalette({ columns, loading, maxSelectable = 0, placedColumns, onColumnRemove }: TableColumnPaletteProps) {
|
|
const [selectedNames, setSelectedNames] = useState<Set<string>>(new Set());
|
|
const [listExpanded, setListExpanded] = useState(false);
|
|
|
|
// 이미 배치된 컬럼을 선택 목록에 자동 포함
|
|
useEffect(() => {
|
|
if (!placedColumns || placedColumns.size === 0) return;
|
|
setSelectedNames((prev) => {
|
|
const next = new Set(prev);
|
|
let changed = false;
|
|
placedColumns.forEach((name) => {
|
|
if (!next.has(name)) {
|
|
next.add(name);
|
|
changed = true;
|
|
}
|
|
});
|
|
return changed ? next : prev;
|
|
});
|
|
}, [placedColumns]);
|
|
|
|
const isLimitReached = maxSelectable > 0 && selectedNames.size >= maxSelectable;
|
|
|
|
const selectedColumns = useMemo(
|
|
() => columns.filter((c) => selectedNames.has(c.column_name)),
|
|
[columns, selectedNames],
|
|
);
|
|
|
|
const toggleColumn = (name: string) => {
|
|
setSelectedNames((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(name)) {
|
|
next.delete(name);
|
|
} else {
|
|
if (maxSelectable > 0 && next.size >= maxSelectable) return prev;
|
|
next.add(name);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Step 1: 컬럼 선택 */}
|
|
<div className="rounded-xl border border-border bg-white">
|
|
<button
|
|
onClick={() => setListExpanded(!listExpanded)}
|
|
className="flex w-full items-center justify-between px-4 py-2.5"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-bold text-gray-800">
|
|
열 선택
|
|
</span>
|
|
<span className="text-xs font-normal text-muted-foreground">
|
|
{selectedNames.size}{maxSelectable > 0 ? `/${maxSelectable}` : `/${columns.length}`} 선택됨
|
|
</span>
|
|
{maxSelectable > 0 && isLimitReached && (
|
|
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-600">
|
|
최대
|
|
</span>
|
|
)}
|
|
</div>
|
|
{listExpanded ? (
|
|
<ChevronUp className="h-4 w-4 text-gray-400" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-gray-400" />
|
|
)}
|
|
</button>
|
|
|
|
{listExpanded && (
|
|
<div className="border-t border-gray-100">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-6">
|
|
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
|
<span className="ml-2 text-xs text-gray-500">컬럼 로딩 중...</span>
|
|
</div>
|
|
) : columns.length === 0 ? (
|
|
<p className="py-4 text-center text-xs text-gray-400">
|
|
레이아웃 탭에서 데이터 소스를 먼저 선택하세요.
|
|
</p>
|
|
) : (
|
|
<>
|
|
{maxSelectable > 0 && (
|
|
<div className="border-b border-gray-100 px-4 py-2">
|
|
<span className="text-[10px] text-gray-400">
|
|
배치 가능 열 수: {maxSelectable}개
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div className="max-h-[200px] overflow-y-auto divide-y divide-gray-50">
|
|
{columns.map((col) => {
|
|
const checked = selectedNames.has(col.column_name);
|
|
const disabled = !checked && isLimitReached;
|
|
return (
|
|
<label
|
|
key={col.column_name}
|
|
className={`flex items-center gap-3 px-4 py-2 transition-colors ${
|
|
disabled
|
|
? "cursor-not-allowed opacity-40"
|
|
: "cursor-pointer hover:bg-gray-50"
|
|
} ${checked ? "bg-blue-50/40" : ""}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
disabled={disabled}
|
|
onChange={() => toggleColumn(col.column_name)}
|
|
className="h-3.5 w-3.5 shrink-0 rounded border-gray-300 text-blue-600 disabled:cursor-not-allowed"
|
|
/>
|
|
<span
|
|
className={`font-mono text-xs font-medium ${
|
|
checked ? "text-blue-700" : "text-gray-700"
|
|
}`}
|
|
>
|
|
{col.column_name}
|
|
</span>
|
|
<span className="rounded bg-gray-100 px-1 py-0.5 text-[9px] text-gray-500">
|
|
{col.data_type}
|
|
</span>
|
|
{col.is_nullable === "NO" && (
|
|
<span className="rounded bg-orange-50 px-1 py-0.5 text-[9px] text-orange-500">
|
|
NOT NULL
|
|
</span>
|
|
)}
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Step 2: 선택된 컬럼 드래그 영역 */}
|
|
{selectedColumns.length > 0 && (
|
|
<div className="rounded-xl border border-border bg-white p-4">
|
|
<div className="mb-3 text-sm font-bold text-gray-800">
|
|
선택된 열
|
|
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
|
드래그하여 아래 표에 배치 ({selectedColumns.length}개)
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{selectedColumns.map((col) => (
|
|
<DraggableColumn
|
|
key={col.column_name}
|
|
column={col}
|
|
placed={placedColumns?.has(col.column_name)}
|
|
onRemove={() => {
|
|
setSelectedNames((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(col.column_name);
|
|
return next;
|
|
});
|
|
onColumnRemove?.(col.column_name);
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|