- 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
1498 lines
68 KiB
TypeScript
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">오름차순 (A→Z, 1→9)</SelectItem>
|
|
<SelectItem value="desc">내림차순 (Z→A, 9→1)</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;
|