ERP-node/frontend/components/report/designer/modals/TableColumnPalette.tsx

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>
);
}