refactor: Enhance label display and drag-and-drop functionality in table configuration

- Updated the InteractiveScreenViewer and InteractiveScreenViewerDynamic components to include label positioning and size adjustments based on horizontal label settings.
- Improved the DynamicComponentRenderer to handle label display logic more robustly, allowing for string values in addition to boolean.
- Introduced drag-and-drop functionality in the TableListConfigPanel for reordering selected columns, enhancing user experience and flexibility in column management.
- Refactored the display name resolution logic to prioritize available column labels, ensuring accurate representation in the UI.
This commit is contained in:
DDD1542 2026-02-27 14:30:31 +09:00
parent 21c0c2b95c
commit 026e99511c
5 changed files with 255 additions and 234 deletions

View File

@ -2233,8 +2233,17 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
...component,
style: {
...component.style,
labelDisplay: false, // 상위에서 라벨을 표시했으므로 컴포넌트 내부에서는 숨김
labelDisplay: false,
labelPosition: "top" as const,
...(isHorizontalLabel ? { width: "100%", height: "100%" } : {}),
},
...(isHorizontalLabel ? {
size: {
...component.size,
width: undefined as unknown as number,
height: undefined as unknown as number,
},
} : {}),
}
: component;

View File

@ -1292,7 +1292,22 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
) : null;
const componentToRender = needsExternalLabel
? { ...splitAdjustedComponent, style: { ...splitAdjustedComponent.style, labelDisplay: false } }
? {
...splitAdjustedComponent,
style: {
...splitAdjustedComponent.style,
labelDisplay: false,
labelPosition: "top" as const,
...(isHorizLabel ? { width: "100%", height: "100%" } : {}),
},
...(isHorizLabel ? {
size: {
...splitAdjustedComponent.size,
width: undefined as unknown as number,
height: undefined as unknown as number,
},
} : {}),
}
: splitAdjustedComponent;
return (

View File

@ -619,9 +619,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
componentType === "modal-repeater-table" ||
componentType === "v2-input";
// 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true일 때만 라벨 표시)
// 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true/"true"일 때만 라벨 표시)
const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
const effectiveLabel = labelDisplay === true
const effectiveLabel = (labelDisplay === true || labelDisplay === "true")
? (component.style?.labelText || (component as any).label || component.componentConfig?.label)
: undefined;

View File

@ -10,11 +10,74 @@ import { TableListConfig, ColumnConfig } from "./types";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2, GripVertical, Pencil } from "lucide-react";
import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2, GripVertical, X } 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 { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
/**
* (v2-split-panel-layout의 SortableColumnRow )
*/
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 TableListConfigPanelProps {
config: TableListConfig;
@ -348,11 +411,11 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
const existingColumn = config.columns?.find((col) => col.columnName === columnName);
if (existingColumn) return;
// tableColumns에서 해당 컬럼의 라벨 정보 찾기
// tableColumns → availableColumns 순서로 한국어 라벨 찾기
const columnInfo = tableColumns?.find((col: any) => (col.columnName || col.name) === columnName);
const availableColumnInfo = availableColumns.find((col) => col.columnName === columnName);
// 라벨명 우선 사용, 없으면 컬럼명 사용
const displayName = columnInfo?.label || columnInfo?.displayName || columnName;
const displayName = columnInfo?.label || columnInfo?.displayName || availableColumnInfo?.label || columnName;
const newColumn: ColumnConfig = {
columnName,
@ -1213,31 +1276,59 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</>
)}
{/* 선택된 컬럼 순서 변경 */}
{/* 선택된 컬럼 순서 변경 (DnD) */}
{config.columns && config.columns.length > 0 && (
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> / </h3>
<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) => c.columnName === active.id);
const newIndex = columns.findIndex((c) => c.columnName === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const reordered = arrayMove(columns, oldIndex, newIndex);
reordered.forEach((col, idx) => { col.order = idx; });
handleChange("columns", reordered);
}
}}
>
<SortableContext
items={(config.columns || []).map((c) => c.columnName)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{[...(config.columns || [])]
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((column, idx) => (
<SelectedColumnItem
{(config.columns || []).map((column, idx) => {
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}
column={column}
id={column.columnName}
col={colWithLabel}
index={idx}
total={config.columns?.length || 0}
onMove={(direction) => moveColumn(column.columnName, direction)}
isEntityJoin={!!column.isEntityJoin}
onLabelChange={(value) => updateColumn(column.columnName, { displayName: value })}
onWidthChange={(value) => updateColumn(column.columnName, { width: value })}
onRemove={() => removeColumn(column.columnName)}
onUpdate={(updates) => updateColumn(column.columnName, updates)}
/>
))}
);
})}
</div>
</SortableContext>
</DndContext>
</div>
)}
@ -1269,96 +1360,3 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
);
};
/**
*
* , ,
*/
const SelectedColumnItem: React.FC<{
column: ColumnConfig;
index: number;
total: number;
onMove: (direction: "up" | "down") => void;
onRemove: () => void;
onUpdate: (updates: Partial<ColumnConfig>) => void;
}> = ({ column, index, total, onMove, onRemove, onUpdate }) => {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(column.displayName || column.columnName);
const handleSave = () => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== column.displayName) {
onUpdate({ displayName: trimmed });
}
setIsEditing(false);
};
return (
<div className="hover:bg-muted/50 group flex items-center gap-1 rounded px-1.5 py-1">
<GripVertical className="text-muted-foreground/40 h-3 w-3 flex-shrink-0" />
<span className="text-muted-foreground min-w-[18px] text-[10px]">{index + 1}</span>
{isEditing ? (
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === "Enter") handleSave();
if (e.key === "Escape") {
setEditValue(column.displayName || column.columnName);
setIsEditing(false);
}
}}
className="h-5 flex-1 px-1 text-xs"
autoFocus
/>
) : (
<button
type="button"
className="flex flex-1 items-center gap-1 truncate text-left text-xs"
onClick={() => {
setEditValue(column.displayName || column.columnName);
setIsEditing(true);
}}
title="클릭하여 표시명 수정"
>
<span className="truncate">{column.displayName || column.columnName}</span>
{column.isEntityJoin && (
<Link2 className="h-2.5 w-2.5 flex-shrink-0 text-blue-500" />
)}
<Pencil className="text-muted-foreground/0 group-hover:text-muted-foreground/60 ml-auto h-2.5 w-2.5 flex-shrink-0 transition-colors" />
</button>
)}
<div className="flex flex-shrink-0 items-center gap-0.5">
<button
type="button"
className="hover:bg-muted disabled:opacity-30 rounded p-0.5 transition-colors"
onClick={() => onMove("up")}
disabled={index === 0}
title="위로 이동"
>
<ArrowUp className="h-3 w-3" />
</button>
<button
type="button"
className="hover:bg-muted disabled:opacity-30 rounded p-0.5 transition-colors"
onClick={() => onMove("down")}
disabled={index === total - 1}
title="아래로 이동"
>
<ArrowDown className="h-3 w-3" />
</button>
<button
type="button"
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded p-0.5 transition-colors"
onClick={onRemove}
title="컬럼 제거"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
);
};

View File

@ -23,12 +23,75 @@ import {
Table2,
Link2,
GripVertical,
Pencil,
X,
} 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 { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
/**
* (v2-split-panel-layout의 SortableColumnRow )
*/
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 TableListConfigPanelProps {
config: TableListConfig;
@ -368,11 +431,11 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
const existingColumn = config.columns?.find((col) => col.columnName === columnName);
if (existingColumn) return;
// tableColumns에서 해당 컬럼의 라벨 정보 찾기
// tableColumns → availableColumns 순서로 한국어 라벨 찾기
const columnInfo = tableColumns?.find((col: any) => (col.columnName || col.name) === columnName);
const availableColumnInfo = availableColumns.find((col) => col.columnName === columnName);
// 라벨명 우선 사용, 없으면 컬럼명 사용
const displayName = columnInfo?.label || columnInfo?.displayName || columnName;
const displayName = columnInfo?.label || columnInfo?.displayName || availableColumnInfo?.label || columnName;
const newColumn: ColumnConfig = {
columnName,
@ -1460,31 +1523,60 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</>
)}
{/* 선택된 컬럼 순서 변경 */}
{/* 선택된 컬럼 순서 변경 (DnD) */}
{config.columns && config.columns.length > 0 && (
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> / </h3>
<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) => c.columnName === active.id);
const newIndex = columns.findIndex((c) => c.columnName === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const reordered = arrayMove(columns, oldIndex, newIndex);
reordered.forEach((col, idx) => { col.order = idx; });
handleChange("columns", reordered);
}
}}
>
<SortableContext
items={(config.columns || []).map((c) => c.columnName)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{[...(config.columns || [])]
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((column, idx) => (
<SelectedColumnItem
{(config.columns || []).map((column, idx) => {
// displayName이 columnName과 같으면 한국어 라벨 미설정 → availableColumns에서 찾기
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}
column={column}
id={column.columnName}
col={colWithLabel}
index={idx}
total={config.columns?.length || 0}
onMove={(direction) => moveColumn(column.columnName, direction)}
isEntityJoin={!!column.isEntityJoin}
onLabelChange={(value) => updateColumn(column.columnName, { displayName: value })}
onWidthChange={(value) => updateColumn(column.columnName, { width: value })}
onRemove={() => removeColumn(column.columnName)}
onUpdate={(updates) => updateColumn(column.columnName, updates)}
/>
))}
);
})}
</div>
</SortableContext>
</DndContext>
</div>
)}
@ -1515,96 +1607,3 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
);
};
/**
*
* , ,
*/
const SelectedColumnItem: React.FC<{
column: ColumnConfig;
index: number;
total: number;
onMove: (direction: "up" | "down") => void;
onRemove: () => void;
onUpdate: (updates: Partial<ColumnConfig>) => void;
}> = ({ column, index, total, onMove, onRemove, onUpdate }) => {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(column.displayName || column.columnName);
const handleSave = () => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== column.displayName) {
onUpdate({ displayName: trimmed });
}
setIsEditing(false);
};
return (
<div className="hover:bg-muted/50 group flex items-center gap-1 rounded px-1.5 py-1">
<GripVertical className="text-muted-foreground/40 h-3 w-3 flex-shrink-0" />
<span className="text-muted-foreground min-w-[18px] text-[10px]">{index + 1}</span>
{isEditing ? (
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === "Enter") handleSave();
if (e.key === "Escape") {
setEditValue(column.displayName || column.columnName);
setIsEditing(false);
}
}}
className="h-5 flex-1 px-1 text-xs"
autoFocus
/>
) : (
<button
type="button"
className="flex flex-1 items-center gap-1 truncate text-left text-xs"
onClick={() => {
setEditValue(column.displayName || column.columnName);
setIsEditing(true);
}}
title="클릭하여 표시명 수정"
>
<span className="truncate">{column.displayName || column.columnName}</span>
{column.isEntityJoin && (
<Link2 className="h-2.5 w-2.5 flex-shrink-0 text-blue-500" />
)}
<Pencil className="text-muted-foreground/0 group-hover:text-muted-foreground/60 ml-auto h-2.5 w-2.5 flex-shrink-0 transition-colors" />
</button>
)}
<div className="flex flex-shrink-0 items-center gap-0.5">
<button
type="button"
className="hover:bg-muted disabled:opacity-30 rounded p-0.5 transition-colors"
onClick={() => onMove("up")}
disabled={index === 0}
title="위로 이동"
>
<ArrowUp className="h-3 w-3" />
</button>
<button
type="button"
className="hover:bg-muted disabled:opacity-30 rounded p-0.5 transition-colors"
onClick={() => onMove("down")}
disabled={index === total - 1}
title="아래로 이동"
>
<ArrowDown className="h-3 w-3" />
</button>
<button
type="button"
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded p-0.5 transition-colors"
onClick={onRemove}
title="컬럼 제거"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
);
};