1467 lines
66 KiB
TypeScript
1467 lines
66 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="max-h-[250px] space-y-0.5 overflow-y-auto">
|
|
{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="max-h-[150px] space-y-0.5 overflow-y-auto 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>
|
|
<span className="ml-auto text-[10px] text-primary/80">
|
|
{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="max-h-[300px] space-y-1 overflow-y-auto">
|
|
{(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;
|