Files
vexplor_dev/frontend/components/v2/config-panels/V2TableListConfigPanel.tsx
kjs 8da48bfe9c feat: enhance V2TableListConfigPanel with editable column locking feature
- Added a button to toggle the editable state of columns in the V2TableListConfigPanel, allowing users to lock or unlock editing for specific columns.
- Implemented visual indicators (lock/unlock icons) to represent the editable state of each column, improving user interaction and clarity.
- Enhanced the button's tooltip to provide context on the current state (editable or locked) when hovered.

These updates aim to improve the usability of the table configuration panel by providing users with more control over column editing capabilities.

Made-with: Cursor
2026-03-16 18:45:43 +09:00

1498 lines
68 KiB
TypeScript

"use client";
/**
* V2TableList 설정 패널
* 토스식 단계별 UX: 데이터 소스 -> 컬럼 선택 -> 조인 컬럼 -> 순서/라벨 -> 고급 설정(접힘)
* 기존 TableListConfigPanel의 모든 기능을 자체 UI로 완전 구현
*/
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Separator } from "@/components/ui/separator";
import {
Table2,
Database,
Link2,
GripVertical,
X,
Check,
ChevronsUpDown,
Lock,
Unlock,
Settings,
ChevronDown,
Loader2,
Columns3,
ArrowUpDown,
Filter,
LayoutGrid,
CheckSquare,
Wrench,
ScrollText,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement";
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";
import type { TableListConfig, ColumnConfig } from "@/lib/registry/components/v2-table-list/types";
// ─── DnD 정렬 가능한 컬럼 행 (접이식) ───
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 };
const [expanded, setExpanded] = useState(false);
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"bg-card rounded-md border px-2.5 py-1.5",
isDragging && "z-50 opacity-50 shadow-md",
isEntityJoin && "border-primary/20 bg-primary/5",
)}
>
<div className="flex items-center gap-1.5">
<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-primary" />
) : (
<span className="text-muted-foreground text-[10px] font-medium">#{index + 1}</span>
)}
<button
type="button"
className="truncate text-xs flex-1 text-left hover:underline"
onClick={() => setExpanded(!expanded)}
>
{col.displayName || col.columnName}
</button>
{col.width && (
<Badge variant="secondary" className="text-[10px] h-5 shrink-0">{col.width}px</Badge>
)}
<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>
{expanded && (
<div className="grid grid-cols-[1fr_60px] gap-1.5 pl-5 mt-1.5">
<Input
value={col.displayName || col.columnName}
onChange={(e) => onLabelChange(e.target.value)}
placeholder="표시명"
className="h-7 min-w-0 text-xs"
/>
<Input
value={col.width || ""}
onChange={(e) => onWidthChange(parseInt(e.target.value) || 100)}
placeholder="너비"
className="h-7 shrink-0 text-xs text-center"
/>
</div>
)}
</div>
);
}
// ─── 섹션 헤더 컴포넌트 ───
function SectionHeader({ icon: Icon, title, description }: { icon: React.ComponentType<{ className?: string }>; title: string; description?: string }) {
return (
<div className="space-y-1">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">{title}</h3>
</div>
{description && <p className="text-muted-foreground text-[10px]">{description}</p>}
</div>
);
}
// ─── 수평 Switch Row (토스 패턴) ───
function SwitchRow({ label, description, checked, onCheckedChange }: {
label: string;
description?: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}) {
return (
<div className="flex items-center justify-between py-1">
<div className="space-y-0.5">
<p className="text-sm">{label}</p>
{description && <p className="text-[11px] text-muted-foreground">{description}</p>}
</div>
<Switch checked={checked} onCheckedChange={onCheckedChange} />
</div>
);
}
interface V2TableListConfigPanelProps {
config: TableListConfig;
onChange: (config: Partial<TableListConfig>) => void;
screenTableName?: string;
tableColumns?: any[];
menuObjid?: number;
}
export const V2TableListConfigPanel: React.FC<V2TableListConfigPanelProps> = ({
config: configProp,
onChange,
screenTableName,
tableColumns,
menuObjid,
}) => {
const config = configProp || ({} as TableListConfig);
// componentConfigChanged 이벤트 발행 래퍼
const handleChange = useCallback((newConfig: Partial<TableListConfig>) => {
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: { ...config, ...newConfig } },
})
);
}
}, [onChange, config]);
// key-value 형태 업데이트 헬퍼
const updateField = useCallback((key: keyof TableListConfig, value: any) => {
handleChange({ ...config, [key]: value });
}, [handleChange, config]);
const updateNestedField = useCallback((parentKey: keyof TableListConfig, childKey: string, value: any) => {
const parentValue = config[parentKey] as any;
handleChange({
...config,
[parentKey]: { ...parentValue, [childKey]: value },
});
}, [handleChange, config]);
// ─── 상태 ───
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
const [availableColumns, setAvailableColumns] = useState<
Array<{ columnName: string; dataType: string; label?: string; input_type?: string }>
>([]);
const [entityJoinColumns, setEntityJoinColumns] = useState<{
availableColumns: Array<{
tableName: string;
columnName: string;
columnLabel: string;
dataType: string;
joinAlias: string;
suggestedLabel: string;
}>;
joinTables: Array<{
tableName: string;
currentDisplayColumn: string;
joinConfig?: any;
availableColumns: Array<{
columnName: string;
columnLabel: string;
dataType: string;
inputType?: string;
description?: string;
}>;
}>;
}>({ availableColumns: [], joinTables: [] });
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
const [referenceTableColumns, setReferenceTableColumns] = useState<
Array<{ columnName: string; dataType: string; label?: string }>
>([]);
const [loadingReferenceColumns, setLoadingReferenceColumns] = useState(false);
const [entityDisplayConfigs, setEntityDisplayConfigs] = useState<
Record<string, {
sourceColumns: Array<{ columnName: string; displayName: string; dataType: string }>;
joinColumns: Array<{ columnName: string; displayName: string; dataType: string }>;
selectedColumns: string[];
separator: string;
}>
>({});
// Collapsible 상태
const [advancedOpen, setAdvancedOpen] = useState(false);
const [entityDisplayOpen, setEntityDisplayOpen] = useState(false);
const [columnSelectOpen, setColumnSelectOpen] = useState(() => (config.columns?.length || 0) > 0);
const [entityJoinOpen, setEntityJoinOpen] = useState(false);
const [displayColumnsOpen, setDisplayColumnsOpen] = useState(() => (config.columns?.length || 0) > 0);
const [columnSearchText, setColumnSearchText] = useState("");
const [entityJoinSubOpen, setEntityJoinSubOpen] = useState<Record<number, boolean>>({});
// 이전 컬럼 개수 추적 (엔티티 감지용)
const prevColumnsLengthRef = useRef<number>(0);
// ─── 실제 사용할 테이블 이름 계산 ───
const targetTableName = useMemo(() => {
if (config.useCustomTable && config.customTableName) {
return config.customTableName;
}
return config.selectedTable || screenTableName;
}, [config.useCustomTable, config.customTableName, config.selectedTable, screenTableName]);
// ─── 초기화: 화면 테이블명 자동 설정 ───
useEffect(() => {
if (screenTableName && !config.selectedTable) {
handleChange({
...config,
selectedTable: screenTableName,
columns: config.columns || [],
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [screenTableName]);
// ─── 테이블 목록 가져오기 ───
useEffect(() => {
const fetchTables = async () => {
setLoadingTables(true);
try {
const response = await tableTypeApi.getTables();
setAvailableTables(
response.map((table: any) => ({
tableName: table.tableName,
displayName: table.displayName || table.tableName,
})),
);
} catch (error) {
console.error("테이블 목록 가져오기 실패:", error);
} finally {
setLoadingTables(false);
}
};
fetchTables();
}, []);
// ─── 선택된 테이블의 컬럼 목록 설정 ───
useEffect(() => {
if (!targetTableName) {
setAvailableColumns([]);
return;
}
const isUsingDifferentTable = config.selectedTable && screenTableName && config.selectedTable !== screenTableName;
const shouldUseTableColumnsProp = !config.useCustomTable && !isUsingDifferentTable && tableColumns && tableColumns.length > 0;
if (shouldUseTableColumnsProp) {
const mappedColumns = tableColumns.map((column: any) => ({
columnName: column.columnName || column.name,
dataType: column.dataType || column.type || "text",
label: column.label || column.displayName || column.columnLabel || column.columnName || column.name,
input_type: column.input_type || column.inputType,
}));
setAvailableColumns(mappedColumns);
if (!config.selectedTable && screenTableName) {
handleChange({
...config,
selectedTable: screenTableName,
columns: config.columns || [],
});
}
} else {
const fetchColumns = async () => {
try {
const result = await tableManagementApi.getColumnList(targetTableName);
if (result.success && result.data) {
const columns = Array.isArray(result.data) ? result.data : result.data.columns;
if (columns && Array.isArray(columns)) {
setAvailableColumns(
columns.map((col: any) => ({
columnName: col.columnName,
dataType: col.dataType,
label: col.displayName || col.columnLabel || col.columnName,
input_type: col.input_type || col.inputType,
})),
);
} else {
setAvailableColumns([]);
}
} else {
setAvailableColumns([]);
}
} catch (error) {
console.error("컬럼 목록 가져오기 실패:", error);
setAvailableColumns([]);
}
};
fetchColumns();
}
}, [targetTableName, config.useCustomTable, tableColumns]);
// ─── Entity 조인 컬럼 정보 가져오기 ───
useEffect(() => {
const fetchEntityJoinColumns = async () => {
if (!targetTableName) {
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
return;
}
setLoadingEntityJoins(true);
try {
const result = await entityJoinApi.getEntityJoinColumns(targetTableName);
setEntityJoinColumns({
availableColumns: result.availableColumns || [],
joinTables: result.joinTables || [],
});
} catch (error) {
console.error("Entity 조인 컬럼 조회 오류:", error);
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
} finally {
setLoadingEntityJoins(false);
}
};
fetchEntityJoinColumns();
}, [targetTableName]);
// ─── 제외 필터용 참조 테이블 컬럼 가져오기 ───
useEffect(() => {
const fetchReferenceColumns = async () => {
const refTable = config.excludeFilter?.referenceTable;
if (!refTable) {
setReferenceTableColumns([]);
return;
}
setLoadingReferenceColumns(true);
try {
const result = await tableManagementApi.getColumnList(refTable);
if (result.success && result.data) {
const columns = result.data.columns || [];
setReferenceTableColumns(
columns.map((col: any) => ({
columnName: col.columnName || col.column_name,
dataType: col.dataType || col.data_type || "text",
label: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
})),
);
}
} catch (error) {
console.error("참조 테이블 컬럼 조회 오류:", error);
setReferenceTableColumns([]);
} finally {
setLoadingReferenceColumns(false);
}
};
fetchReferenceColumns();
}, [config.excludeFilter?.referenceTable]);
// ─── 엔티티 컬럼 자동 로드 ───
useEffect(() => {
const entityColumns = config.columns?.filter((col) => col.isEntityJoin && col.entityDisplayConfig);
if (!entityColumns || entityColumns.length === 0) return;
entityColumns.forEach((column) => {
if (entityDisplayConfigs[column.columnName]) return;
loadEntityDisplayConfig(column);
});
}, [config.columns]);
// ─── 엔티티 타입 컬럼 자동 감지 ───
useEffect(() => {
const currentLength = config.columns?.length || 0;
const prevLength = prevColumnsLengthRef.current;
if (!config.columns || !tableColumns || config.columns.length === 0) {
prevColumnsLengthRef.current = currentLength;
return;
}
if (currentLength === prevLength && prevLength > 0) return;
const updatedColumns = config.columns.map((column) => {
if (column.isEntityJoin) return column;
const tableColumn = tableColumns.find((tc: any) => tc.columnName === column.columnName);
if (tableColumn && (tableColumn.input_type === "entity" || tableColumn.web_type === "entity")) {
return {
...column,
isEntityJoin: true,
entityJoinInfo: {
sourceTable: config.selectedTable || screenTableName || "",
sourceColumn: column.columnName,
joinAlias: column.columnName,
},
entityDisplayConfig: {
displayColumns: [],
separator: " - ",
sourceTable: config.selectedTable || screenTableName || "",
joinTable: tableColumn.reference_table || tableColumn.referenceTable || "",
},
};
}
return column;
});
const hasChanges = updatedColumns.some((col, index) => col.isEntityJoin !== config.columns![index].isEntityJoin);
if (hasChanges) {
updateField("columns", updatedColumns);
}
prevColumnsLengthRef.current = currentLength;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.columns?.length, tableColumns, config.selectedTable]);
// ─── 엔티티 컬럼의 표시 컬럼 정보 로드 ───
const loadEntityDisplayConfig = useCallback(async (column: ColumnConfig) => {
const configKey = column.columnName;
if (entityDisplayConfigs[configKey]) return;
if (!column.isEntityJoin) {
setEntityDisplayConfigs((prev) => ({
...prev,
[configKey]: { sourceColumns: [], joinColumns: [], selectedColumns: [], separator: " - " },
}));
return;
}
const sourceTable =
column.entityDisplayConfig?.sourceTable ||
column.entityJoinInfo?.sourceTable ||
config.selectedTable ||
screenTableName;
if (!sourceTable) {
setEntityDisplayConfigs((prev) => ({
...prev,
[configKey]: {
sourceColumns: [],
joinColumns: [],
selectedColumns: column.entityDisplayConfig?.displayColumns || [],
separator: column.entityDisplayConfig?.separator || " - ",
},
}));
return;
}
let joinTable = column.entityDisplayConfig?.joinTable;
if (!joinTable) {
try {
const columnList = await tableTypeApi.getColumns(sourceTable);
const columnInfo = columnList.find((col: any) => (col.column_name || col.columnName) === column.columnName);
if (columnInfo?.reference_table || columnInfo?.referenceTable) {
joinTable = columnInfo.reference_table || columnInfo.referenceTable;
const updatedDisplayConfig = {
...column.entityDisplayConfig,
sourceTable,
joinTable,
displayColumns: column.entityDisplayConfig?.displayColumns || [],
separator: column.entityDisplayConfig?.separator || " - ",
};
const updatedColumns = config.columns?.map((col) =>
col.columnName === column.columnName ? { ...col, entityDisplayConfig: updatedDisplayConfig } : col,
);
if (updatedColumns) updateField("columns", updatedColumns);
}
} catch (error) {
console.error("tableTypeApi 컬럼 정보 조회 실패:", error);
}
}
try {
const sourceResult = await entityJoinApi.getReferenceTableColumns(sourceTable);
const sourceColumns = sourceResult.columns || [];
let joinColumns: Array<{ columnName: string; displayName: string; dataType: string }> = [];
if (joinTable) {
try {
const joinResult = await entityJoinApi.getReferenceTableColumns(joinTable);
joinColumns = joinResult.columns || [];
} catch {
// 조인 테이블 로드 실패해도 소스 테이블 컬럼은 표시
}
}
setEntityDisplayConfigs((prev) => ({
...prev,
[configKey]: {
sourceColumns,
joinColumns,
selectedColumns: column.entityDisplayConfig?.displayColumns || [],
separator: column.entityDisplayConfig?.separator || " - ",
},
}));
} catch (error) {
console.error("엔티티 표시 컬럼 정보 로드 실패:", error);
setEntityDisplayConfigs((prev) => ({
...prev,
[configKey]: {
sourceColumns: [],
joinColumns: [],
selectedColumns: column.entityDisplayConfig?.displayColumns || [],
separator: column.entityDisplayConfig?.separator || " - ",
},
}));
}
}, [entityDisplayConfigs, config.selectedTable, config.columns, screenTableName, updateField]);
// ─── 엔티티 표시 컬럼 선택 토글 ───
const toggleEntityDisplayColumn = useCallback((columnName: string, selectedColumn: string) => {
const configKey = columnName;
const localConfig = entityDisplayConfigs[configKey];
if (!localConfig) return;
const newSelectedColumns = localConfig.selectedColumns.includes(selectedColumn)
? localConfig.selectedColumns.filter((col) => col !== selectedColumn)
: [...localConfig.selectedColumns, selectedColumn];
setEntityDisplayConfigs((prev) => ({
...prev,
[configKey]: { ...prev[configKey], selectedColumns: newSelectedColumns },
}));
const updatedColumns = config.columns?.map((col) => {
if (col.columnName === columnName && col.entityDisplayConfig) {
return { ...col, entityDisplayConfig: { ...col.entityDisplayConfig, displayColumns: newSelectedColumns } };
}
return col;
});
if (updatedColumns) updateField("columns", updatedColumns);
}, [entityDisplayConfigs, config.columns, updateField]);
// ─── 엔티티 표시 구분자 업데이트 ───
const updateEntityDisplaySeparator = useCallback((columnName: string, separator: string) => {
const configKey = columnName;
const localConfig = entityDisplayConfigs[configKey];
if (!localConfig) return;
setEntityDisplayConfigs((prev) => ({
...prev,
[configKey]: { ...prev[configKey], separator },
}));
const updatedColumns = config.columns?.map((col) => {
if (col.columnName === columnName && col.entityDisplayConfig) {
return { ...col, entityDisplayConfig: { ...col.entityDisplayConfig, separator } };
}
return col;
});
if (updatedColumns) updateField("columns", updatedColumns);
}, [entityDisplayConfigs, config.columns, updateField]);
// ─── 컬럼 추가 ───
const addColumn = useCallback((columnName: string) => {
const existingColumn = config.columns?.find((col) => col.columnName === columnName);
if (existingColumn) return;
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 || availableColumnInfo?.label || columnName;
const newColumn: ColumnConfig = {
columnName,
displayName,
visible: true,
sortable: true,
searchable: true,
align: "left",
format: "text",
order: config.columns?.length || 0,
};
updateField("columns", [...(config.columns || []), newColumn]);
}, [config.columns, tableColumns, availableColumns, updateField]);
// ─── 조인 컬럼 추가 ───
const addEntityColumn = useCallback((joinColumn: (typeof entityJoinColumns.availableColumns)[0]) => {
const existingColumn = config.columns?.find((col) => col.columnName === joinColumn.joinAlias);
if (existingColumn) return;
const joinTableInfo = entityJoinColumns.joinTables?.find((jt: any) => jt.tableName === joinColumn.tableName);
const sourceColumn = (joinTableInfo as any)?.joinConfig?.sourceColumn || "";
const newColumn: ColumnConfig = {
columnName: joinColumn.joinAlias,
displayName: joinColumn.columnLabel,
visible: true,
sortable: true,
searchable: true,
align: "left",
format: "text",
order: config.columns?.length || 0,
isEntityJoin: false,
additionalJoinInfo: {
sourceTable: config.selectedTable || screenTableName || "",
sourceColumn,
referenceTable: joinColumn.tableName,
joinAlias: joinColumn.joinAlias,
},
};
updateField("columns", [...(config.columns || []), newColumn]);
}, [config.columns, entityJoinColumns.joinTables, config.selectedTable, screenTableName, updateField]);
// ─── 컬럼 제거 ───
const removeColumn = useCallback((columnName: string) => {
updateField("columns", config.columns?.filter((col) => col.columnName !== columnName) || []);
}, [config.columns, updateField]);
// ─── 컬럼 업데이트 ───
const updateColumn = useCallback((columnName: string, updates: Partial<ColumnConfig>) => {
const updatedColumns = config.columns?.map((col) =>
col.columnName === columnName ? { ...col, ...updates } : col
) || [];
updateField("columns", updatedColumns);
}, [config.columns, updateField]);
// ─── 테이블 변경 핸들러 ───
const handleTableChange = useCallback((newTableName: string) => {
if (newTableName === targetTableName) return;
handleChange({
...config,
selectedTable: newTableName,
columns: [],
});
setTableComboboxOpen(false);
}, [targetTableName, handleChange, config]);
// ─── 렌더링 ───
return (
<div className="space-y-4">
{/* ═══════════════════════════════════════ */}
{/* 1단계: 데이터 소스 (테이블 선택) */}
{/* ═══════════════════════════════════════ */}
<div className="space-y-3">
<SectionHeader icon={Table2} title="데이터 소스" description="테이블을 선택하세요. 미선택 시 화면 메인 테이블을 사용합니다." />
<Separator />
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingTables}
>
<div className="flex items-center gap-2 truncate">
<Table2 className="h-3 w-3 shrink-0" />
<span className="truncate">
{loadingTables
? "테이블 로딩 중..."
: targetTableName
? availableTables.find((t) => t.tableName === targetTableName)?.displayName || targetTableName
: "테이블 선택"}
</span>
</div>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName}`}
onSelect={() => handleTableChange(table.tableName)}
className="text-xs"
>
<Check
className={cn("mr-2 h-3 w-3", targetTableName === table.tableName ? "opacity-100" : "opacity-0")}
/>
<div className="flex flex-col">
<span>{table.displayName}</span>
{table.displayName !== table.tableName && (
<span className="text-[10px] text-muted-foreground/70">{table.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{screenTableName && targetTableName !== screenTableName && (
<div className="flex items-center justify-between rounded bg-amber-50 px-2 py-1 dark:bg-amber-950/30">
<span className="text-[10px] text-amber-700 dark:text-amber-400">
({screenTableName})
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-[10px] text-amber-700 hover:text-amber-900 dark:text-amber-400"
onClick={() => handleTableChange(screenTableName)}
>
</Button>
</div>
)}
</div>
{/* ═══════════════════════════════════════ */}
{/* 2단계: 컬럼 선택 (Collapsible) */}
{/* ═══════════════════════════════════════ */}
{targetTableName && availableColumns.length > 0 && (
<>
<Collapsible open={columnSelectOpen} onOpenChange={setColumnSelectOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Columns3 className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="text-[10px] h-5">
{config.columns?.filter((c) => !c.isEntityJoin && !c.additionalJoinInfo).length || 0}
</Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", columnSelectOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-2">
<Input
value={columnSearchText}
onChange={(e) => setColumnSearchText(e.target.value)}
placeholder="컬럼 검색..."
className="h-7 text-xs"
/>
<div className="space-y-0.5">
{availableColumns
.filter((column) => {
if (!columnSearchText) return true;
const search = columnSearchText.toLowerCase();
return (
column.columnName.toLowerCase().includes(search) ||
(column.label || "").toLowerCase().includes(search)
);
})
.map((column) => {
const isAdded = config.columns?.some((c) => 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) {
updateField("columns", config.columns?.filter((c) => c.columnName !== column.columnName) || []);
} else {
addColumn(column.columnName);
}
}}
>
<Checkbox
checked={isAdded}
onCheckedChange={() => {
if (isAdded) {
updateField("columns", config.columns?.filter((c) => c.columnName !== column.columnName) || []);
} else {
addColumn(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) => c.columnName === column.columnName)?.editable === false
? "편집 잠금 (클릭하여 해제)"
: "편집 가능 (클릭하여 잠금)"
}
className={cn(
"ml-auto flex-shrink-0 rounded p-0.5 transition-colors",
config.columns?.find((c) => 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) => c.columnName === column.columnName);
if (currentCol) {
updateColumn(column.columnName, {
editable: currentCol.editable === false ? undefined : false,
});
}
}}
>
{config.columns?.find((c) => 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-muted-foreground/70", !isAdded && "ml-auto")}>
{column.input_type || column.dataType}
</span>
</div>
);
})}
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* Entity 조인 컬럼 (Collapsible) */}
{entityJoinColumns.joinTables.length > 0 && (
<Collapsible open={entityJoinOpen} onOpenChange={setEntityJoinOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Entity </span>
<Badge variant="secondary" className="text-[10px] h-5">
{entityJoinColumns.joinTables.length}
</Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", entityJoinOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-2">
{entityJoinColumns.joinTables.map((joinTable, tableIndex) => {
const addedCount = joinTable.availableColumns.filter((col) => {
const match = entityJoinColumns.availableColumns.find(
(jc) => jc.tableName === joinTable.tableName && jc.columnName === col.columnName,
);
return match && config.columns?.some((c) => c.columnName === match.joinAlias);
}).length;
const isSubOpen = entityJoinSubOpen[tableIndex] ?? false;
return (
<Collapsible key={tableIndex} open={isSubOpen} onOpenChange={(open) => setEntityJoinSubOpen((prev) => ({ ...prev, [tableIndex]: open }))}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-left transition-colors hover:bg-primary/10"
>
<div className="flex items-center gap-2">
<Link2 className="h-3 w-3 text-primary" />
<span className="truncate text-xs font-medium">{joinTable.tableName}</span>
<Badge variant="secondary" className="text-[10px] h-5">
{addedCount > 0 ? `${addedCount}/${joinTable.availableColumns.length}개 선택` : `${joinTable.availableColumns.length}개 컬럼`}
</Badge>
</div>
<ChevronDown className={cn("h-3 w-3 text-muted-foreground transition-transform duration-200", isSubOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-0.5 rounded-b-md border border-t-0 border-primary/20 bg-primary/5 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) => 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-primary/10",
isAlreadyAdded && "bg-primary/10",
)}
onClick={() => {
if (isAlreadyAdded) {
updateField("columns", config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) || []);
} else {
addEntityColumn(matchingJoinColumn);
}
}}
>
<Checkbox
checked={isAlreadyAdded}
onCheckedChange={() => {
if (isAlreadyAdded) {
updateField("columns", config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) || []);
} else {
addEntityColumn(matchingJoinColumn);
}
}}
className="pointer-events-none h-3.5 w-3.5"
/>
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
<span className="truncate text-xs">{column.columnLabel}</span>
{isAlreadyAdded && (
<button
type="button"
title={
config.columns?.find((c) => c.columnName === matchingJoinColumn.joinAlias)?.editable === false
? "편집 잠금 (클릭하여 해제)"
: "편집 가능 (클릭하여 잠금)"
}
className={cn(
"ml-auto flex-shrink-0 rounded p-0.5 transition-colors",
config.columns?.find((c) => c.columnName === matchingJoinColumn.joinAlias)?.editable === false
? "text-destructive hover:bg-destructive/10"
: "text-muted-foreground hover:bg-muted",
)}
onClick={(e) => {
e.stopPropagation();
const currentCol = config.columns?.find((c) => c.columnName === matchingJoinColumn.joinAlias);
if (currentCol) {
updateColumn(matchingJoinColumn.joinAlias, {
editable: currentCol.editable === false ? undefined : false,
});
}
}}
>
{config.columns?.find((c) => c.columnName === matchingJoinColumn.joinAlias)?.editable === false ? (
<Lock className="h-3 w-3" />
) : (
<Unlock className="h-3 w-3" />
)}
</button>
)}
<span className={cn("text-[10px] text-primary/80", !isAlreadyAdded && "ml-auto")}>
{column.inputType || column.dataType}
</span>
</div>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
)}
</>
)}
{/* 테이블 미선택 또는 컬럼 없음 안내 */}
{!targetTableName && (
<div className="rounded-lg border-2 border-dashed p-6 text-center">
<Table2 className="mx-auto mb-2 h-8 w-8 text-muted-foreground opacity-30" />
<p className="text-sm text-muted-foreground"> </p>
<p className="text-xs text-muted-foreground"> </p>
</div>
)}
{targetTableName && availableColumns.length === 0 && (
<div className="rounded-lg border-2 border-dashed p-6 text-center">
<Columns3 className="mx-auto mb-2 h-8 w-8 text-muted-foreground opacity-30" />
<p className="text-sm text-muted-foreground"> ...</p>
<p className="mt-2 text-xs text-primary"> : {screenTableName}</p>
</div>
)}
{/* ═══════════════════════════════════════ */}
{/* 3단계: 표시할 컬럼 (Collapsible + DnD) */}
{/* ═══════════════════════════════════════ */}
{config.columns && config.columns.length > 0 && (
<Collapsible open={displayColumnsOpen} onOpenChange={setDisplayColumnsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="text-[10px] h-5">
{config.columns.length}
</Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", displayColumnsOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3">
<p className="text-[10px] text-muted-foreground mb-2"> , / </p>
<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; });
updateField("columns", reordered);
}
}}
>
<SortableContext
items={(config.columns || []).map((c) => c.columnName)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{(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}
id={column.columnName}
col={colWithLabel}
index={idx}
isEntityJoin={!!column.isEntityJoin}
onLabelChange={(value) => updateColumn(column.columnName, { displayName: value })}
onWidthChange={(value) => updateColumn(column.columnName, { width: value })}
onRemove={() => removeColumn(column.columnName)}
/>
);
})}
</div>
</SortableContext>
</DndContext>
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* ═══════════════════════════════════════ */}
{/* 엔티티 컬럼 표시 설정 (접이식) */}
{/* ═══════════════════════════════════════ */}
{config.columns?.some((col) => col.isEntityJoin) && (
<Collapsible open={entityDisplayOpen} onOpenChange={setEntityDisplayOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", entityDisplayOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-4">
{config.columns
?.filter((col) => col.isEntityJoin && col.entityDisplayConfig)
.map((column) => (
<div key={column.columnName} className="space-y-2">
<span className="text-xs font-medium">{column.displayName || column.columnName}</span>
{entityDisplayConfigs[column.columnName] ? (
<div className="space-y-2">
{/* 구분자 */}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground"></span>
<Input
value={entityDisplayConfigs[column.columnName].separator}
onChange={(e) => updateEntityDisplaySeparator(column.columnName, e.target.value)}
className="h-6 w-20 text-xs"
placeholder=" - "
/>
</div>
{/* 표시 컬럼 선택 */}
{entityDisplayConfigs[column.columnName].sourceColumns.length === 0 &&
entityDisplayConfigs[column.columnName].joinColumns.length === 0 ? (
<div className="py-2 text-center text-xs text-muted-foreground/70">
.
{!column.entityDisplayConfig?.joinTable && (
<p className="mt-1 text-[10px]">
.
</p>
)}
</div>
) : (
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> </span>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-6 w-full justify-between text-xs">
{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={() => toggleEntityDisplayColumn(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={() => toggleEntityDisplayColumn(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-primary/10 p-2 text-[10px] text-primary">
. .
</div>
)}
{/* 선택된 컬럼 미리보기 */}
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0 && (
<div className="space-y-1">
<span className="text-xs text-muted-foreground"></span>
<div className="flex flex-wrap gap-1 rounded bg-muted 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-muted-foreground/70">
{entityDisplayConfigs[column.columnName].separator}
</span>
)}
</React.Fragment>
))}
</div>
</div>
)}
</div>
) : (
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
)}
{config.columns?.filter((c) => c.isEntityJoin && c.entityDisplayConfig).indexOf(column) !==
(config.columns?.filter((c) => c.isEntityJoin && c.entityDisplayConfig).length || 0) - 1 && (
<Separator className="my-2" />
)}
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* ═══════════════════════════════════════ */}
{/* 4단계: 툴바 버튼 설정 (Switch 토글) */}
{/* ═══════════════════════════════════════ */}
<div className="space-y-3">
<SectionHeader icon={Wrench} title="툴바 버튼" description="테이블 상단에 표시할 버튼을 선택합니다" />
<Separator />
<div className="space-y-1">
<SwitchRow
label="즉시 저장"
description="인라인 편집 후 즉시 저장하는 모드 버튼"
checked={config.toolbar?.showEditMode ?? false}
onCheckedChange={(checked) => updateNestedField("toolbar", "showEditMode", checked)}
/>
<SwitchRow
label="Excel 내보내기"
checked={config.toolbar?.showExcel ?? false}
onCheckedChange={(checked) => updateNestedField("toolbar", "showExcel", checked)}
/>
<SwitchRow
label="PDF 내보내기"
checked={config.toolbar?.showPdf ?? false}
onCheckedChange={(checked) => updateNestedField("toolbar", "showPdf", checked)}
/>
<SwitchRow
label="복사"
checked={config.toolbar?.showCopy ?? false}
onCheckedChange={(checked) => updateNestedField("toolbar", "showCopy", checked)}
/>
<SwitchRow
label="검색"
checked={config.toolbar?.showSearch ?? false}
onCheckedChange={(checked) => updateNestedField("toolbar", "showSearch", checked)}
/>
<SwitchRow
label="필터"
checked={config.toolbar?.showFilter ?? false}
onCheckedChange={(checked) => updateNestedField("toolbar", "showFilter", checked)}
/>
<SwitchRow
label="새로고침 (상단)"
checked={config.toolbar?.showRefresh ?? false}
onCheckedChange={(checked) => updateNestedField("toolbar", "showRefresh", checked)}
/>
<SwitchRow
label="새로고침 (하단)"
checked={config.toolbar?.showPaginationRefresh ?? true}
onCheckedChange={(checked) => updateNestedField("toolbar", "showPaginationRefresh", checked)}
/>
</div>
</div>
{/* ═══════════════════════════════════════ */}
{/* 5단계: 고급 설정 (기본 접힘) */}
{/* ═══════════════════════════════════════ */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", advancedOpen && "rotate-180")}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-5">
{/* 체크박스 설정 */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<CheckSquare className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium"></span>
</div>
<SwitchRow
label="체크박스 표시"
description="행 선택용 체크박스를 표시합니다"
checked={config.checkbox?.enabled ?? true}
onCheckedChange={(checked) => updateNestedField("checkbox", "enabled", checked)}
/>
{config.checkbox?.enabled && (
<div className="ml-4 space-y-2 border-l-2 border-primary/20 pl-3">
<SwitchRow
label="전체 선택"
description="헤더에 전체 선택/해제 체크박스 표시"
checked={config.checkbox?.selectAll ?? true}
onCheckedChange={(checked) => updateNestedField("checkbox", "selectAll", checked)}
/>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={config.checkbox?.position || "left"}
onValueChange={(value) => updateNestedField("checkbox", "position", value)}
>
<SelectTrigger className="h-7 w-[100px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
<Separator />
{/* 기본 정렬 설정 */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<ArrowUpDown className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium"> </span>
</div>
<p className="text-[10px] text-muted-foreground"> </p>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={config.defaultSort?.columnName || "_none_"}
onValueChange={(value) => {
if (value === "_none_") {
updateField("defaultSort", undefined);
} else {
updateField("defaultSort", {
columnName: value,
direction: config.defaultSort?.direction || "asc",
});
}
}}
>
<SelectTrigger className="h-7 w-[160px] text-xs">
<SelectValue placeholder="정렬 없음" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"> </SelectItem>
{availableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.label || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{config.defaultSort?.columnName && (
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={config.defaultSort?.direction || "asc"}
onValueChange={(value) =>
updateField("defaultSort", {
...config.defaultSort,
columnName: config.defaultSort?.columnName || "",
direction: value as "asc" | "desc",
})
}
>
<SelectTrigger className="h-7 w-[160px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="asc"> (AZ, 19)</SelectItem>
<SelectItem value="desc"> (ZA, 91)</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<Separator />
{/* 가로 스크롤 */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<ScrollText className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium"> </span>
</div>
<SwitchRow
label="가로 스크롤 사용"
description="컬럼이 많을 때 가로 스크롤을 활성화합니다"
checked={config.horizontalScroll?.enabled ?? false}
onCheckedChange={(checked) => updateNestedField("horizontalScroll", "enabled", checked)}
/>
{config.horizontalScroll?.enabled && (
<div className="ml-4 border-l-2 border-primary/20 pl-3">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-xs"> </p>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
<Input
type="number"
value={config.horizontalScroll?.maxVisibleColumns || 8}
onChange={(e) => updateNestedField("horizontalScroll", "maxVisibleColumns", parseInt(e.target.value) || 8)}
min={3}
max={20}
className="h-7 w-[80px] text-xs"
/>
</div>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* ═══════════════════════════════════════ */}
{/* 6단계: 데이터 필터링 */}
{/* ═══════════════════════════════════════ */}
<div className="space-y-3">
<SectionHeader icon={Filter} title="데이터 필터링" description="특정 컬럼 값으로 데이터를 필터링합니다" />
<Separator />
<DataFilterConfigPanel
tableName={config.selectedTable || screenTableName}
columns={availableColumns.map(
(col) =>
({
columnName: col.columnName,
columnLabel: col.label || col.columnName,
dataType: col.dataType,
input_type: col.input_type,
}) as any,
)}
config={config.dataFilter}
onConfigChange={(dataFilter) => updateField("dataFilter", dataFilter)}
/>
</div>
</div>
);
};
V2TableListConfigPanel.displayName = "V2TableListConfigPanel";
export default V2TableListConfigPanel;