772 lines
32 KiB
TypeScript
772 lines
32 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* V2TableGrouped 설정 패널
|
|
* 토스식 단계별 UX: 데이터 소스 -> 그룹화 설정 -> 컬럼 선택 -> 표시 설정(접힘) -> 연동 설정(접힘)
|
|
* 기존 TableGroupedConfigPanel의 모든 기능을 자체 UI로 완전 구현
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
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,
|
|
Layers,
|
|
Columns3,
|
|
Check,
|
|
ChevronsUpDown,
|
|
Settings,
|
|
ChevronDown,
|
|
Loader2,
|
|
Link2,
|
|
Plus,
|
|
Trash2,
|
|
FoldVertical,
|
|
ArrowUpDown,
|
|
CheckSquare,
|
|
LayoutGrid,
|
|
Type,
|
|
Hash,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { tableTypeApi } from "@/lib/api/screen";
|
|
import type { TableGroupedConfig, LinkedFilterConfig } from "@/lib/registry/components/v2-table-grouped/types";
|
|
import type { ColumnConfig } from "@/lib/registry/components/v2-table-list/types";
|
|
import {
|
|
groupHeaderStyleOptions,
|
|
checkboxModeOptions,
|
|
sortDirectionOptions,
|
|
} from "@/lib/registry/components/v2-table-grouped/config";
|
|
|
|
// ─── 섹션 헤더 컴포넌트 ───
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ─── 수평 라벨 + 컨트롤 Row ───
|
|
function LabeledRow({ label, description, children }: {
|
|
label: string;
|
|
description?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center justify-between py-1">
|
|
<div className="space-y-0.5">
|
|
<p className="text-xs text-muted-foreground">{label}</p>
|
|
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 그룹 헤더 스타일 카드 ───
|
|
const HEADER_STYLE_CARDS = [
|
|
{ value: "default", icon: LayoutGrid, title: "기본", description: "표준 그룹 헤더" },
|
|
{ value: "compact", icon: FoldVertical, title: "컴팩트", description: "간결한 헤더" },
|
|
{ value: "card", icon: Layers, title: "카드", description: "카드 스타일 헤더" },
|
|
] as const;
|
|
|
|
interface V2TableGroupedConfigPanelProps {
|
|
config: TableGroupedConfig;
|
|
onChange: (newConfig: Partial<TableGroupedConfig>) => void;
|
|
}
|
|
|
|
export const V2TableGroupedConfigPanel: React.FC<V2TableGroupedConfigPanelProps> = ({
|
|
config,
|
|
onChange,
|
|
}) => {
|
|
// componentConfigChanged 이벤트 발행 래퍼
|
|
const handleChange = useCallback((newConfig: Partial<TableGroupedConfig>) => {
|
|
onChange(newConfig);
|
|
if (typeof window !== "undefined") {
|
|
window.dispatchEvent(
|
|
new CustomEvent("componentConfigChanged", {
|
|
detail: { config: { ...config, ...newConfig } },
|
|
})
|
|
);
|
|
}
|
|
}, [onChange, config]);
|
|
|
|
const updateConfig = useCallback((updates: Partial<TableGroupedConfig>) => {
|
|
handleChange({ ...config, ...updates });
|
|
}, [handleChange, config]);
|
|
|
|
const updateGroupConfig = useCallback((updates: Partial<TableGroupedConfig["groupConfig"]>) => {
|
|
handleChange({
|
|
...config,
|
|
groupConfig: { ...config.groupConfig, ...updates },
|
|
});
|
|
}, [handleChange, config]);
|
|
|
|
// ─── 상태 ───
|
|
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
|
const [tableColumns, setTableColumns] = useState<ColumnConfig[]>([]);
|
|
const [loadingTables, setLoadingTables] = useState(false);
|
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
|
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
|
|
|
// Collapsible 상태
|
|
const [displayOpen, setDisplayOpen] = useState(false);
|
|
const [linkedOpen, setLinkedOpen] = useState(false);
|
|
|
|
// ─── 실제 사용할 테이블 이름 ───
|
|
const targetTableName = useMemo(() => {
|
|
if (config.useCustomTable && config.customTableName) {
|
|
return config.customTableName;
|
|
}
|
|
return config.selectedTable;
|
|
}, [config.useCustomTable, config.customTableName, config.selectedTable]);
|
|
|
|
// ─── 테이블 목록 로드 ───
|
|
useEffect(() => {
|
|
const loadTables = async () => {
|
|
setLoadingTables(true);
|
|
try {
|
|
const tableList = await tableTypeApi.getTables();
|
|
if (tableList && Array.isArray(tableList)) {
|
|
setTables(
|
|
tableList.map((t: any) => ({
|
|
tableName: t.tableName || t.table_name,
|
|
displayName: t.displayName || t.display_name || t.tableName || t.table_name,
|
|
}))
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.error("테이블 목록 로드 실패:", err);
|
|
} finally {
|
|
setLoadingTables(false);
|
|
}
|
|
};
|
|
loadTables();
|
|
}, []);
|
|
|
|
// ─── 선택된 테이블의 컬럼 로드 ───
|
|
useEffect(() => {
|
|
if (!targetTableName) {
|
|
setTableColumns([]);
|
|
return;
|
|
}
|
|
|
|
const loadColumns = async () => {
|
|
setLoadingColumns(true);
|
|
try {
|
|
const columns = await tableTypeApi.getColumns(targetTableName);
|
|
if (columns && Array.isArray(columns)) {
|
|
const cols: ColumnConfig[] = columns.map((col: any, idx: number) => ({
|
|
columnName: col.column_name || col.columnName,
|
|
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
|
|
visible: true,
|
|
sortable: true,
|
|
searchable: false,
|
|
align: "left" as const,
|
|
order: idx,
|
|
}));
|
|
setTableColumns(cols);
|
|
|
|
if (!config.columns || config.columns.length === 0) {
|
|
updateConfig({ columns: cols });
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("컬럼 로드 실패:", err);
|
|
} finally {
|
|
setLoadingColumns(false);
|
|
}
|
|
};
|
|
loadColumns();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [targetTableName]);
|
|
|
|
// ─── 테이블 변경 핸들러 ───
|
|
const handleTableChange = useCallback((newTableName: string) => {
|
|
if (newTableName === config.selectedTable) return;
|
|
updateConfig({ selectedTable: newTableName, columns: [] });
|
|
setTableComboboxOpen(false);
|
|
}, [config.selectedTable, updateConfig]);
|
|
|
|
// ─── 컬럼 가시성 토글 ───
|
|
const toggleColumnVisibility = useCallback((columnName: string) => {
|
|
const updatedColumns = (config.columns || []).map((col) =>
|
|
col.columnName === columnName ? { ...col, visible: !col.visible } : col
|
|
);
|
|
updateConfig({ columns: updatedColumns });
|
|
}, [config.columns, updateConfig]);
|
|
|
|
// ─── 합계 컬럼 토글 ───
|
|
const toggleSumColumn = useCallback((columnName: string) => {
|
|
const currentSumCols = config.groupConfig?.summary?.sumColumns || [];
|
|
const newSumCols = currentSumCols.includes(columnName)
|
|
? currentSumCols.filter((c) => c !== columnName)
|
|
: [...currentSumCols, columnName];
|
|
|
|
updateGroupConfig({
|
|
summary: {
|
|
...config.groupConfig?.summary,
|
|
sumColumns: newSumCols,
|
|
},
|
|
});
|
|
}, [config.groupConfig?.summary, updateGroupConfig]);
|
|
|
|
// ─── 연결 필터 관리 ───
|
|
const addLinkedFilter = useCallback(() => {
|
|
const newFilter: LinkedFilterConfig = {
|
|
sourceComponentId: "",
|
|
sourceField: "value",
|
|
targetColumn: "",
|
|
enabled: true,
|
|
};
|
|
updateConfig({
|
|
linkedFilters: [...(config.linkedFilters || []), newFilter],
|
|
});
|
|
}, [config.linkedFilters, updateConfig]);
|
|
|
|
const removeLinkedFilter = useCallback((index: number) => {
|
|
const filters = [...(config.linkedFilters || [])];
|
|
filters.splice(index, 1);
|
|
updateConfig({ linkedFilters: filters });
|
|
}, [config.linkedFilters, updateConfig]);
|
|
|
|
const updateLinkedFilter = useCallback((index: number, updates: Partial<LinkedFilterConfig>) => {
|
|
const filters = [...(config.linkedFilters || [])];
|
|
filters[index] = { ...filters[index], ...updates };
|
|
updateConfig({ linkedFilters: filters });
|
|
}, [config.linkedFilters, updateConfig]);
|
|
|
|
// ─── 렌더링 ───
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* ═══════════════════════════════════════ */}
|
|
{/* 1단계: 데이터 소스 (테이블 선택) */}
|
|
{/* ═══════════════════════════════════════ */}
|
|
<div className="space-y-3">
|
|
<SectionHeader icon={Table2} title="데이터 소스" description="그룹화할 테이블을 선택하세요" />
|
|
<Separator />
|
|
|
|
<SwitchRow
|
|
label="커스텀 테이블 사용"
|
|
description="화면 메인 테이블 대신 다른 테이블을 사용합니다"
|
|
checked={config.useCustomTable ?? false}
|
|
onCheckedChange={(checked) => updateConfig({ useCustomTable: checked })}
|
|
/>
|
|
|
|
{config.useCustomTable ? (
|
|
<Input
|
|
value={config.customTableName || ""}
|
|
onChange={(e) => updateConfig({ customTableName: e.target.value })}
|
|
placeholder="테이블명을 직접 입력하세요"
|
|
className="h-8 text-xs"
|
|
/>
|
|
) : (
|
|
<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
|
|
? "테이블 로딩 중..."
|
|
: config.selectedTable
|
|
? tables.find((t) => t.tableName === config.selectedTable)?.displayName || config.selectedTable
|
|
: "테이블 선택"}
|
|
</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
|
|
filter={(value, search) => {
|
|
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
|
|
return 0;
|
|
}}
|
|
>
|
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
|
<CommandList>
|
|
<CommandEmpty className="text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
|
<CommandGroup>
|
|
{tables.map((table) => (
|
|
<CommandItem
|
|
key={table.tableName}
|
|
value={`${table.displayName} ${table.tableName}`}
|
|
onSelect={() => handleTableChange(table.tableName)}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn("mr-2 h-3 w-3", config.selectedTable === 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>
|
|
)}
|
|
</div>
|
|
|
|
{/* ═══════════════════════════════════════ */}
|
|
{/* 2단계: 그룹화 설정 */}
|
|
{/* ═══════════════════════════════════════ */}
|
|
{targetTableName && (
|
|
<div className="space-y-3">
|
|
<SectionHeader icon={Layers} title="그룹화 설정" description="데이터를 어떤 컬럼 기준으로 그룹화할지 설정합니다" />
|
|
<Separator />
|
|
|
|
{/* 그룹화 기준 컬럼 */}
|
|
<LabeledRow label="그룹화 기준 컬럼 *">
|
|
<Select
|
|
value={config.groupConfig?.groupByColumn || ""}
|
|
onValueChange={(value) => updateGroupConfig({ groupByColumn: value })}
|
|
>
|
|
<SelectTrigger className="h-7 w-[160px] text-xs">
|
|
<SelectValue placeholder="컬럼 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{(loadingColumns ? [] : tableColumns).map((col) => (
|
|
<SelectItem key={col.columnName} value={col.columnName}>
|
|
{col.displayName || col.columnName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</LabeledRow>
|
|
|
|
{/* 그룹 라벨 형식 */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<Type className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-xs font-medium truncate">그룹 라벨 형식</span>
|
|
</div>
|
|
<Input
|
|
value={config.groupConfig?.groupLabelFormat || "{value}"}
|
|
onChange={(e) => updateGroupConfig({ groupLabelFormat: e.target.value })}
|
|
placeholder="{value} ({컬럼명})"
|
|
className="h-7 text-xs"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{"{value}"} = 그룹값, {"{컬럼명}"} = 해당 컬럼 값
|
|
</p>
|
|
</div>
|
|
|
|
<SwitchRow
|
|
label="기본 펼침 상태"
|
|
description="그룹이 기본으로 펼쳐진 상태로 표시됩니다"
|
|
checked={config.groupConfig?.defaultExpanded ?? true}
|
|
onCheckedChange={(checked) => updateGroupConfig({ defaultExpanded: checked })}
|
|
/>
|
|
|
|
{/* 그룹 정렬 */}
|
|
<LabeledRow label="그룹 정렬">
|
|
<Select
|
|
value={config.groupConfig?.sortDirection || "asc"}
|
|
onValueChange={(value: string) => updateGroupConfig({ sortDirection: value as "asc" | "desc" })}
|
|
>
|
|
<SelectTrigger className="h-7 w-[120px] text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{sortDirectionOptions.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</LabeledRow>
|
|
|
|
<SwitchRow
|
|
label="개수 표시"
|
|
description="그룹 헤더에 항목 수를 표시합니다"
|
|
checked={config.groupConfig?.summary?.showCount ?? true}
|
|
onCheckedChange={(checked) =>
|
|
updateGroupConfig({
|
|
summary: { ...config.groupConfig?.summary, showCount: checked },
|
|
})
|
|
}
|
|
/>
|
|
|
|
{/* 합계 컬럼 */}
|
|
{tableColumns.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<Hash className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-xs font-medium truncate">합계 표시 컬럼</span>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground">그룹별 합계를 계산할 컬럼을 선택하세요</p>
|
|
<div className="max-h-28 space-y-0.5 overflow-y-auto rounded-md border p-2">
|
|
{tableColumns.map((col) => {
|
|
const isChecked = config.groupConfig?.summary?.sumColumns?.includes(col.columnName) ?? false;
|
|
return (
|
|
<div
|
|
key={col.columnName}
|
|
className={cn(
|
|
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted/50",
|
|
isChecked && "bg-primary/10",
|
|
)}
|
|
onClick={() => toggleSumColumn(col.columnName)}
|
|
>
|
|
<Checkbox
|
|
checked={isChecked}
|
|
onCheckedChange={() => toggleSumColumn(col.columnName)}
|
|
className="pointer-events-none h-3.5 w-3.5"
|
|
/>
|
|
<span className="truncate text-xs">{col.displayName || col.columnName}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 테이블 미선택 안내 */}
|
|
{!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>
|
|
)}
|
|
|
|
{/* ═══════════════════════════════════════ */}
|
|
{/* 3단계: 컬럼 선택 */}
|
|
{/* ═══════════════════════════════════════ */}
|
|
{targetTableName && (config.columns || tableColumns).length > 0 && (
|
|
<div className="space-y-3">
|
|
<SectionHeader
|
|
icon={Columns3}
|
|
title={`컬럼 선택 (${(config.columns || tableColumns).filter((c) => c.visible !== false).length}개 표시)`}
|
|
description="표시할 컬럼을 선택하세요"
|
|
/>
|
|
<Separator />
|
|
|
|
<div className="max-h-48 space-y-0.5 overflow-y-auto rounded-md border p-2">
|
|
{(config.columns || tableColumns).map((col) => {
|
|
const isVisible = col.visible !== false;
|
|
return (
|
|
<div
|
|
key={col.columnName}
|
|
className={cn(
|
|
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted/50",
|
|
isVisible && "bg-primary/10",
|
|
)}
|
|
onClick={() => toggleColumnVisibility(col.columnName)}
|
|
>
|
|
<Checkbox
|
|
checked={isVisible}
|
|
onCheckedChange={() => toggleColumnVisibility(col.columnName)}
|
|
className="pointer-events-none h-3.5 w-3.5"
|
|
/>
|
|
<Database className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
|
<span className="truncate text-xs">{col.displayName || col.columnName}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ═══════════════════════════════════════ */}
|
|
{/* 4단계: 그룹 헤더 스타일 (카드 선택) */}
|
|
{/* ═══════════════════════════════════════ */}
|
|
{targetTableName && (
|
|
<div className="space-y-3">
|
|
<SectionHeader icon={LayoutGrid} title="그룹 헤더 스타일" description="그룹 헤더의 디자인을 선택하세요" />
|
|
<Separator />
|
|
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{HEADER_STYLE_CARDS.map((card) => {
|
|
const Icon = card.icon;
|
|
const isSelected = (config.groupHeaderStyle || "default") === card.value;
|
|
return (
|
|
<button
|
|
key={card.value}
|
|
type="button"
|
|
onClick={() => updateConfig({ groupHeaderStyle: card.value as "default" | "compact" | "card" })}
|
|
className={cn(
|
|
"flex min-h-[70px] flex-col items-center justify-center rounded-lg border p-2.5 text-center transition-all",
|
|
isSelected
|
|
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
|
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
|
)}
|
|
>
|
|
<Icon className="mb-1 h-4 w-4 text-primary" />
|
|
<span className="text-xs font-medium leading-tight">{card.title}</span>
|
|
<span className="mt-0.5 text-[10px] leading-tight text-muted-foreground">{card.description}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ═══════════════════════════════════════ */}
|
|
{/* 5단계: 표시 설정 (기본 접힘) */}
|
|
{/* ═══════════════════════════════════════ */}
|
|
<Collapsible open={displayOpen} onOpenChange={setDisplayOpen}>
|
|
<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 truncate">표시 설정</span>
|
|
<Badge variant="secondary" className="text-[10px] h-5">6개</Badge>
|
|
</div>
|
|
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", displayOpen && "rotate-180")} />
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
|
|
|
|
{/* 체크박스 */}
|
|
<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 truncate">체크박스</span>
|
|
</div>
|
|
|
|
<SwitchRow
|
|
label="체크박스 표시"
|
|
description="행 선택용 체크박스를 표시합니다"
|
|
checked={config.showCheckbox ?? false}
|
|
onCheckedChange={(checked) => updateConfig({ showCheckbox: checked })}
|
|
/>
|
|
|
|
{config.showCheckbox && (
|
|
<div className="ml-4 border-l-2 border-primary/20 pl-3">
|
|
<LabeledRow label="선택 모드">
|
|
<Select
|
|
value={config.checkboxMode || "multi"}
|
|
onValueChange={(value: string) => updateConfig({ checkboxMode: value as "single" | "multi" })}
|
|
>
|
|
<SelectTrigger className="h-7 w-[120px] text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{checkboxModeOptions.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</LabeledRow>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* UI 옵션 */}
|
|
<SwitchRow
|
|
label="펼치기/접기 버튼 표시"
|
|
description="전체 펼치기/접기 버튼을 상단에 표시합니다"
|
|
checked={config.showExpandAllButton ?? true}
|
|
onCheckedChange={(checked) => updateConfig({ showExpandAllButton: checked })}
|
|
/>
|
|
|
|
<SwitchRow
|
|
label="행 클릭 가능"
|
|
description="행 클릭 시 이벤트를 발생시킵니다"
|
|
checked={config.rowClickable ?? true}
|
|
onCheckedChange={(checked) => updateConfig({ rowClickable: checked })}
|
|
/>
|
|
|
|
<Separator />
|
|
|
|
{/* 높이 및 메시지 */}
|
|
<LabeledRow label="최대 높이 (px)">
|
|
<Input
|
|
type="number"
|
|
value={typeof config.maxHeight === "number" ? config.maxHeight : 600}
|
|
onChange={(e) => updateConfig({ maxHeight: parseInt(e.target.value) || 600 })}
|
|
min={200}
|
|
max={2000}
|
|
className="h-7 w-[100px] text-xs"
|
|
/>
|
|
</LabeledRow>
|
|
|
|
<div className="space-y-1">
|
|
<span className="text-xs text-muted-foreground truncate">빈 데이터 메시지</span>
|
|
<Input
|
|
value={config.emptyMessage || ""}
|
|
onChange={(e) => updateConfig({ emptyMessage: e.target.value })}
|
|
placeholder="데이터가 없습니다."
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
{/* ═══════════════════════════════════════ */}
|
|
{/* 6단계: 연동 설정 (기본 접힘) */}
|
|
{/* ═══════════════════════════════════════ */}
|
|
<Collapsible open={linkedOpen} onOpenChange={setLinkedOpen}>
|
|
<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 truncate">연동 설정</span>
|
|
<Badge variant="secondary" className="text-[10px] h-5">{config.linkedFilters?.length || 0}개</Badge>
|
|
</div>
|
|
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", linkedOpen && "rotate-180")} />
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-[10px] text-muted-foreground">
|
|
다른 컴포넌트(검색필터 등)의 선택 값으로 이 테이블을 필터링합니다
|
|
</p>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={addLinkedFilter}
|
|
className="h-6 shrink-0 px-2 text-xs"
|
|
>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
|
|
{(config.linkedFilters || []).length === 0 ? (
|
|
<div className="rounded-lg border-2 border-dashed py-4 text-center">
|
|
<Link2 className="mx-auto mb-1 h-6 w-6 text-muted-foreground opacity-30" />
|
|
<p className="text-xs text-muted-foreground">연결된 필터가 없습니다</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{(config.linkedFilters || []).map((filter, idx) => (
|
|
<div key={idx} className="space-y-2 rounded-md border p-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-medium">필터 #{idx + 1}</span>
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
checked={filter.enabled !== false}
|
|
onCheckedChange={(checked) => updateLinkedFilter(idx, { enabled: checked })}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeLinkedFilter(idx)}
|
|
className="h-5 w-5 p-0 text-destructive hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<span className="text-[10px] text-muted-foreground">소스 컴포넌트 ID</span>
|
|
<Input
|
|
value={filter.sourceComponentId}
|
|
onChange={(e) => updateLinkedFilter(idx, { sourceComponentId: e.target.value })}
|
|
placeholder="예: search-filter-1"
|
|
className="h-6 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<span className="text-[10px] text-muted-foreground">소스 필드</span>
|
|
<Input
|
|
value={filter.sourceField || "value"}
|
|
onChange={(e) => updateLinkedFilter(idx, { sourceField: e.target.value })}
|
|
placeholder="value"
|
|
className="h-6 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<span className="text-[10px] text-muted-foreground">대상 컬럼</span>
|
|
<Select
|
|
value={filter.targetColumn}
|
|
onValueChange={(value) => updateLinkedFilter(idx, { targetColumn: value })}
|
|
>
|
|
<SelectTrigger className="h-6 text-xs">
|
|
<SelectValue placeholder="컬럼 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{tableColumns.map((col) => (
|
|
<SelectItem key={col.columnName} value={col.columnName}>
|
|
{col.displayName || col.columnName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
V2TableGroupedConfigPanel.displayName = "V2TableGroupedConfigPanel";
|
|
|
|
export default V2TableGroupedConfigPanel;
|