535 lines
24 KiB
TypeScript
535 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { ColumnConfig } from "../types";
|
|
import { Database, Link2, GripVertical, X, Check, ChevronsUpDown, Lock, Unlock } from "lucide-react";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
import { cn } from "@/lib/utils";
|
|
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
|
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
|
|
function SortableColumnRow({
|
|
id,
|
|
col,
|
|
index,
|
|
isEntityJoin,
|
|
onLabelChange,
|
|
onWidthChange,
|
|
onRemove,
|
|
}: {
|
|
id: string;
|
|
col: ColumnConfig;
|
|
index: number;
|
|
isEntityJoin?: boolean;
|
|
onLabelChange: (value: string) => void;
|
|
onWidthChange: (value: number) => void;
|
|
onRemove: () => void;
|
|
}) {
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
|
const style = { transform: CSS.Transform.toString(transform), transition };
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className={cn(
|
|
"bg-card flex items-center gap-1.5 rounded-md border px-2 py-1.5",
|
|
isDragging && "z-50 opacity-50 shadow-md",
|
|
isEntityJoin && "border-blue-200 bg-blue-50/30",
|
|
)}
|
|
>
|
|
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
|
|
<GripVertical className="h-3 w-3" />
|
|
</div>
|
|
{isEntityJoin ? (
|
|
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
|
) : (
|
|
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
|
|
)}
|
|
<Input
|
|
value={col.displayName || col.columnName}
|
|
onChange={(e) => onLabelChange(e.target.value)}
|
|
placeholder="표시명"
|
|
className="h-6 min-w-0 flex-1 text-xs"
|
|
/>
|
|
<Input
|
|
value={col.width || ""}
|
|
onChange={(e) => onWidthChange(parseInt(e.target.value) || 100)}
|
|
placeholder="너비"
|
|
className="h-6 w-14 shrink-0 text-xs"
|
|
/>
|
|
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface ColumnsConfigPanelProps {
|
|
config: any;
|
|
onChange: (key: string, value: any) => void;
|
|
screenTableName?: string;
|
|
targetTableName: string | undefined;
|
|
availableColumns: Array<{ columnName: string; dataType: string; label?: string; input_type?: string }>;
|
|
tableColumns?: any[];
|
|
entityJoinColumns: {
|
|
availableColumns: Array<{
|
|
tableName: string;
|
|
columnName: string;
|
|
columnLabel: string;
|
|
dataType: string;
|
|
joinAlias: string;
|
|
suggestedLabel: string;
|
|
}>;
|
|
joinTables: Array<{
|
|
tableName: string;
|
|
currentDisplayColumn: string;
|
|
availableColumns: Array<{
|
|
columnName: string;
|
|
columnLabel: string;
|
|
dataType: string;
|
|
description?: string;
|
|
}>;
|
|
}>;
|
|
};
|
|
entityDisplayConfigs: Record<
|
|
string,
|
|
{
|
|
sourceColumns: Array<{ columnName: string; displayName: string; dataType: string }>;
|
|
joinColumns: Array<{ columnName: string; displayName: string; dataType: string }>;
|
|
selectedColumns: string[];
|
|
separator: string;
|
|
}
|
|
>;
|
|
onAddColumn: (columnName: string) => void;
|
|
onAddEntityColumn: (joinColumn: {
|
|
tableName: string;
|
|
columnName: string;
|
|
columnLabel: string;
|
|
dataType: string;
|
|
joinAlias: string;
|
|
suggestedLabel: string;
|
|
}) => void;
|
|
onRemoveColumn: (columnName: string) => void;
|
|
onUpdateColumn: (columnName: string, updates: Partial<ColumnConfig>) => void;
|
|
onToggleEntityDisplayColumn: (columnName: string, selectedColumn: string) => void;
|
|
onUpdateEntityDisplaySeparator: (columnName: string, separator: string) => void;
|
|
}
|
|
|
|
/**
|
|
* 컬럼 설정 패널: 컬럼 선택, Entity 조인, DnD 순서 변경
|
|
*/
|
|
export const ColumnsConfigPanel: React.FC<ColumnsConfigPanelProps> = ({
|
|
config,
|
|
onChange,
|
|
screenTableName,
|
|
targetTableName,
|
|
availableColumns,
|
|
tableColumns,
|
|
entityJoinColumns,
|
|
entityDisplayConfigs,
|
|
onAddColumn,
|
|
onAddEntityColumn,
|
|
onRemoveColumn,
|
|
onUpdateColumn,
|
|
onToggleEntityDisplayColumn,
|
|
onUpdateEntityDisplaySeparator,
|
|
}) => {
|
|
const handleChange = (key: string, value: any) => {
|
|
onChange(key, value);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* 엔티티 컬럼 표시 설정 섹션 */}
|
|
{config.columns?.some((col: ColumnConfig) => col.isEntityJoin) && (
|
|
<div className="space-y-3">
|
|
{config.columns
|
|
?.filter((col: ColumnConfig) => col.isEntityJoin && col.entityDisplayConfig)
|
|
.map((column: ColumnConfig) => (
|
|
<div key={column.columnName} className="space-y-2">
|
|
<div className="mb-2">
|
|
<span className="truncate text-xs font-medium" style={{ fontSize: "12px" }}>
|
|
{column.displayName || column.columnName}
|
|
</span>
|
|
</div>
|
|
|
|
{entityDisplayConfigs[column.columnName] ? (
|
|
<div className="space-y-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">구분자</Label>
|
|
<Input
|
|
value={entityDisplayConfigs[column.columnName].separator}
|
|
onChange={(e) => onUpdateEntityDisplaySeparator(column.columnName, e.target.value)}
|
|
className="h-6 w-full text-xs"
|
|
style={{ fontSize: "12px" }}
|
|
placeholder=" - "
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">표시할 컬럼 선택</Label>
|
|
{entityDisplayConfigs[column.columnName].sourceColumns.length === 0 &&
|
|
entityDisplayConfigs[column.columnName].joinColumns.length === 0 ? (
|
|
<div className="py-2 text-center text-xs text-gray-400">
|
|
표시 가능한 컬럼이 없습니다.
|
|
{!column.entityDisplayConfig?.joinTable && (
|
|
<p className="mt-1 text-[10px]">
|
|
테이블 타입 관리에서 참조 테이블을 설정하면 더 많은 컬럼을 선택할 수 있습니다.
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
className="h-6 w-full justify-between text-xs"
|
|
style={{ fontSize: "12px" }}
|
|
>
|
|
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0
|
|
? `${entityDisplayConfigs[column.columnName].selectedColumns.length}개 선택됨`
|
|
: "컬럼 선택"}
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-full p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
|
<CommandList>
|
|
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
|
{entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
|
|
<CommandGroup
|
|
heading={`기본 테이블: ${column.entityDisplayConfig?.sourceTable || config.selectedTable || screenTableName}`}
|
|
>
|
|
{entityDisplayConfigs[column.columnName].sourceColumns.map((col) => (
|
|
<CommandItem
|
|
key={`source-${col.columnName}`}
|
|
onSelect={() => onToggleEntityDisplayColumn(column.columnName, col.columnName)}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
entityDisplayConfigs[column.columnName].selectedColumns.includes(col.columnName)
|
|
? "opacity-100"
|
|
: "opacity-0",
|
|
)}
|
|
/>
|
|
{col.displayName}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
{entityDisplayConfigs[column.columnName].joinColumns.length > 0 && (
|
|
<CommandGroup heading={`참조 테이블: ${column.entityDisplayConfig?.joinTable}`}>
|
|
{entityDisplayConfigs[column.columnName].joinColumns.map((col) => (
|
|
<CommandItem
|
|
key={`join-${col.columnName}`}
|
|
onSelect={() => onToggleEntityDisplayColumn(column.columnName, col.columnName)}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
entityDisplayConfigs[column.columnName].selectedColumns.includes(col.columnName)
|
|
? "opacity-100"
|
|
: "opacity-0",
|
|
)}
|
|
/>
|
|
{col.displayName}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
</div>
|
|
|
|
{!column.entityDisplayConfig?.joinTable &&
|
|
entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
|
|
<div className="rounded bg-blue-50 p-2 text-[10px] text-blue-600">
|
|
현재 기본 테이블 컬럼만 표시됩니다. 테이블 타입 관리에서 참조 테이블을 설정하면 조인된
|
|
테이블의 컬럼도 선택할 수 있습니다.
|
|
</div>
|
|
)}
|
|
|
|
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0 && (
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">미리보기</Label>
|
|
<div className="flex flex-wrap gap-1 rounded bg-gray-50 p-2 text-xs">
|
|
{entityDisplayConfigs[column.columnName].selectedColumns.map((colName, idx) => (
|
|
<React.Fragment key={colName}>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{colName}
|
|
</Badge>
|
|
{idx < entityDisplayConfigs[column.columnName].selectedColumns.length - 1 && (
|
|
<span className="text-gray-400">
|
|
{entityDisplayConfigs[column.columnName].separator}
|
|
</span>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="py-4 text-center text-xs text-gray-400">컬럼 정보 로딩 중...</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!targetTableName ? (
|
|
<div className="space-y-3">
|
|
<div className="text-center text-gray-500">
|
|
<p>테이블이 선택되지 않았습니다.</p>
|
|
<p className="text-sm">기본 설정 탭에서 테이블을 선택하세요.</p>
|
|
</div>
|
|
</div>
|
|
) : availableColumns.length === 0 ? (
|
|
<div className="space-y-3">
|
|
<div className="text-center text-gray-500">
|
|
<p>컬럼을 추가하려면 먼저 컴포넌트에 테이블을 명시적으로 선택하거나</p>
|
|
<p className="text-sm">기본 설정 탭에서 테이블을 설정해주세요.</p>
|
|
<p className="mt-2 text-xs text-blue-600">현재 화면 테이블: {screenTableName}</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="space-y-2">
|
|
<div>
|
|
<h3 className="text-sm font-semibold">컬럼 선택</h3>
|
|
<p className="text-muted-foreground text-[10px]">표시할 컬럼을 선택하세요</p>
|
|
</div>
|
|
<hr className="border-border" />
|
|
<div className="max-h-48 space-y-0.5 overflow-y-auto rounded-md border p-2">
|
|
{availableColumns.map((column) => {
|
|
const isAdded = config.columns?.some((c: ColumnConfig) => c.columnName === column.columnName);
|
|
return (
|
|
<div
|
|
key={column.columnName}
|
|
className={cn(
|
|
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
|
|
isAdded && "bg-primary/10",
|
|
)}
|
|
onClick={() => {
|
|
if (isAdded) {
|
|
handleChange(
|
|
"columns",
|
|
config.columns?.filter((c: ColumnConfig) => c.columnName !== column.columnName) || [],
|
|
);
|
|
} else {
|
|
onAddColumn(column.columnName);
|
|
}
|
|
}}
|
|
>
|
|
<Checkbox
|
|
checked={isAdded}
|
|
onCheckedChange={() => {
|
|
if (isAdded) {
|
|
handleChange(
|
|
"columns",
|
|
config.columns?.filter((c: ColumnConfig) => c.columnName !== column.columnName) || [],
|
|
);
|
|
} else {
|
|
onAddColumn(column.columnName);
|
|
}
|
|
}}
|
|
className="pointer-events-none h-3.5 w-3.5"
|
|
/>
|
|
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
|
<span className="truncate text-xs">{column.label || column.columnName}</span>
|
|
{isAdded && (
|
|
<button
|
|
type="button"
|
|
title={
|
|
config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName)?.editable === false
|
|
? "편집 잠금 (클릭하여 해제)"
|
|
: "편집 가능 (클릭하여 잠금)"
|
|
}
|
|
className={cn(
|
|
"ml-auto flex-shrink-0 rounded p-0.5 transition-colors",
|
|
config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName)?.editable === false
|
|
? "text-destructive hover:bg-destructive/10"
|
|
: "text-muted-foreground hover:bg-muted",
|
|
)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
const currentCol = config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName);
|
|
if (currentCol) {
|
|
onUpdateColumn(column.columnName, {
|
|
editable: currentCol.editable === false ? undefined : false,
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
{config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName)?.editable === false ? (
|
|
<Lock className="h-3 w-3" />
|
|
) : (
|
|
<Unlock className="h-3 w-3" />
|
|
)}
|
|
</button>
|
|
)}
|
|
<span className={cn("text-[10px] text-gray-400", !isAdded && "ml-auto")}>
|
|
{column.input_type || column.dataType}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{entityJoinColumns.joinTables.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div>
|
|
<h3 className="text-sm font-semibold">Entity 조인 컬럼</h3>
|
|
<p className="text-muted-foreground text-[10px]">연관 테이블의 컬럼을 선택하세요</p>
|
|
</div>
|
|
<hr className="border-border" />
|
|
<div className="space-y-3">
|
|
{entityJoinColumns.joinTables.map((joinTable, tableIndex) => (
|
|
<div key={tableIndex} className="space-y-1">
|
|
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-blue-600">
|
|
<Link2 className="h-3 w-3" />
|
|
<span>{joinTable.tableName}</span>
|
|
<Badge variant="outline" className="text-[10px]">
|
|
{joinTable.currentDisplayColumn}
|
|
</Badge>
|
|
</div>
|
|
<div className="max-h-28 space-y-0.5 overflow-y-auto rounded-md border border-blue-200 bg-blue-50/30 p-2">
|
|
{joinTable.availableColumns.map((column, colIndex) => {
|
|
const matchingJoinColumn = entityJoinColumns.availableColumns.find(
|
|
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
|
|
);
|
|
const isAlreadyAdded = config.columns?.some(
|
|
(col: ColumnConfig) => col.columnName === matchingJoinColumn?.joinAlias,
|
|
);
|
|
if (!matchingJoinColumn) return null;
|
|
|
|
return (
|
|
<div
|
|
key={colIndex}
|
|
className={cn(
|
|
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-blue-100/50",
|
|
isAlreadyAdded && "bg-blue-100",
|
|
)}
|
|
onClick={() => {
|
|
if (isAlreadyAdded) {
|
|
handleChange(
|
|
"columns",
|
|
config.columns?.filter(
|
|
(c: ColumnConfig) => c.columnName !== matchingJoinColumn.joinAlias,
|
|
) || [],
|
|
);
|
|
} else {
|
|
onAddEntityColumn(matchingJoinColumn);
|
|
}
|
|
}}
|
|
>
|
|
<Checkbox
|
|
checked={isAlreadyAdded}
|
|
onCheckedChange={() => {
|
|
if (isAlreadyAdded) {
|
|
handleChange(
|
|
"columns",
|
|
config.columns?.filter(
|
|
(c: ColumnConfig) => c.columnName !== matchingJoinColumn.joinAlias,
|
|
) || [],
|
|
);
|
|
} else {
|
|
onAddEntityColumn(matchingJoinColumn);
|
|
}
|
|
}}
|
|
className="pointer-events-none h-3.5 w-3.5"
|
|
/>
|
|
<Link2 className="h-3 w-3 flex-shrink-0 text-blue-500" />
|
|
<span className="truncate text-xs">{column.columnLabel}</span>
|
|
<span className="ml-auto text-[10px] text-blue-400">
|
|
{column.inputType || column.dataType}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{config.columns && config.columns.length > 0 && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<h3 className="text-sm font-semibold">표시할 컬럼 ({config.columns.length}개 선택)</h3>
|
|
<p className="text-muted-foreground text-[10px]">
|
|
드래그하여 순서를 변경하거나 표시명/너비를 수정할 수 있습니다
|
|
</p>
|
|
</div>
|
|
<hr className="border-border" />
|
|
<DndContext
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={(event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
const columns = [...(config.columns || [])];
|
|
const oldIndex = columns.findIndex((c: ColumnConfig) => c.columnName === active.id);
|
|
const newIndex = columns.findIndex((c: ColumnConfig) => c.columnName === over.id);
|
|
if (oldIndex !== -1 && newIndex !== -1) {
|
|
const reordered = arrayMove(columns, oldIndex, newIndex);
|
|
reordered.forEach((col: ColumnConfig, idx: number) => {
|
|
col.order = idx;
|
|
});
|
|
handleChange("columns", reordered);
|
|
}
|
|
}}
|
|
>
|
|
<SortableContext
|
|
items={(config.columns || []).map((c: ColumnConfig) => c.columnName)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<div className="space-y-1">
|
|
{(config.columns || []).map((column: ColumnConfig, idx: number) => {
|
|
const resolvedLabel =
|
|
column.displayName && column.displayName !== column.columnName
|
|
? column.displayName
|
|
: availableColumns.find((c) => c.columnName === column.columnName)?.label ||
|
|
column.displayName ||
|
|
column.columnName;
|
|
const colWithLabel = { ...column, displayName: resolvedLabel };
|
|
return (
|
|
<SortableColumnRow
|
|
key={column.columnName}
|
|
id={column.columnName}
|
|
col={colWithLabel}
|
|
index={idx}
|
|
isEntityJoin={!!column.isEntityJoin}
|
|
onLabelChange={(value) => onUpdateColumn(column.columnName, { displayName: value })}
|
|
onWidthChange={(value) => onUpdateColumn(column.columnName, { width: value })}
|
|
onRemove={() => onRemoveColumn(column.columnName)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|