564 lines
19 KiB
TypeScript
564 lines
19 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* V2TableSearchWidget 설정 패널
|
|
* 토스식 단계별 UX: 대상 패널 카드 선택 -> 필터 모드 카드 선택 -> 고정 필터 목록 -> 고급 설정(접힘)
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from "@/components/ui/collapsible";
|
|
import {
|
|
PanelLeft,
|
|
PanelRight,
|
|
Layers,
|
|
Zap,
|
|
Lock,
|
|
Plus,
|
|
Trash2,
|
|
Settings,
|
|
ChevronDown,
|
|
Search,
|
|
Filter,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
// ─── 대상 패널 위치 카드 정의 ───
|
|
const PANEL_POSITION_CARDS = [
|
|
{
|
|
value: "left",
|
|
icon: PanelLeft,
|
|
title: "좌측 패널",
|
|
description: "카드 디스플레이 등",
|
|
},
|
|
{
|
|
value: "right",
|
|
icon: PanelRight,
|
|
title: "우측 패널",
|
|
description: "테이블 리스트 등",
|
|
},
|
|
{
|
|
value: "auto",
|
|
icon: Layers,
|
|
title: "자동",
|
|
description: "모든 테이블 대상",
|
|
},
|
|
] as const;
|
|
|
|
// ─── 필터 모드 카드 정의 ───
|
|
const FILTER_MODE_CARDS = [
|
|
{
|
|
value: "dynamic",
|
|
icon: Zap,
|
|
title: "동적 모드",
|
|
description: "사용자가 직접 필터를 선택해요",
|
|
},
|
|
{
|
|
value: "preset",
|
|
icon: Lock,
|
|
title: "고정 모드",
|
|
description: "디자이너가 미리 필터를 지정해요",
|
|
},
|
|
] as const;
|
|
|
|
// ─── 필터 타입 옵션 ───
|
|
const FILTER_TYPE_OPTIONS = [
|
|
{ value: "text", label: "텍스트" },
|
|
{ value: "number", label: "숫자" },
|
|
{ value: "date", label: "날짜" },
|
|
{ value: "select", label: "선택" },
|
|
] as const;
|
|
|
|
interface PresetFilter {
|
|
id: string;
|
|
columnName: string;
|
|
columnLabel: string;
|
|
filterType: "text" | "number" | "date" | "select";
|
|
width?: number;
|
|
multiSelect?: boolean;
|
|
}
|
|
|
|
// ─── 수평 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>
|
|
);
|
|
}
|
|
|
|
// ─── 섹션 헤더 컴포넌트 ───
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ─── inputType에서 filterType 추출 헬퍼 ───
|
|
function getFilterTypeFromInputType(
|
|
inputType: string
|
|
): "text" | "number" | "date" | "select" {
|
|
if (
|
|
inputType.includes("number") ||
|
|
inputType.includes("decimal") ||
|
|
inputType.includes("integer")
|
|
) {
|
|
return "number";
|
|
}
|
|
if (inputType.includes("date") || inputType.includes("time")) {
|
|
return "date";
|
|
}
|
|
if (
|
|
inputType.includes("select") ||
|
|
inputType.includes("dropdown") ||
|
|
inputType.includes("code") ||
|
|
inputType.includes("category")
|
|
) {
|
|
return "select";
|
|
}
|
|
return "text";
|
|
}
|
|
|
|
interface V2TableSearchWidgetConfigPanelProps {
|
|
config: Record<string, any>;
|
|
onChange: (config: Record<string, any>) => void;
|
|
tables?: any[];
|
|
}
|
|
|
|
export const V2TableSearchWidgetConfigPanel: React.FC<
|
|
V2TableSearchWidgetConfigPanelProps
|
|
> = ({ config: configProp, onChange, tables = [] }) => {
|
|
const config = configProp || {};
|
|
|
|
// componentConfigChanged 이벤트 발행 래퍼
|
|
const handleChange = useCallback(
|
|
(newConfig: Record<string, any>) => {
|
|
onChange(newConfig);
|
|
if (typeof window !== "undefined") {
|
|
window.dispatchEvent(
|
|
new CustomEvent("componentConfigChanged", {
|
|
detail: { config: newConfig },
|
|
})
|
|
);
|
|
}
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
// key-value 형태 업데이트 헬퍼
|
|
const updateField = useCallback(
|
|
(key: string, value: any) => {
|
|
handleChange({ ...config, [key]: value });
|
|
},
|
|
[handleChange, config]
|
|
);
|
|
|
|
// 첫 번째 테이블의 컬럼 목록
|
|
const availableColumns =
|
|
tables.length > 0 && tables[0].columns ? tables[0].columns : [];
|
|
|
|
// ─── 로컬 상태 ───
|
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
|
const [localPresetFilters, setLocalPresetFilters] = useState<PresetFilter[]>(
|
|
config.presetFilters ?? []
|
|
);
|
|
|
|
// config 외부 변경 시 로컬 상태 동기화
|
|
useEffect(() => {
|
|
setLocalPresetFilters(config.presetFilters ?? []);
|
|
}, [config.presetFilters]);
|
|
|
|
// 현재 config 값들
|
|
const targetPanelPosition = config.targetPanelPosition ?? "left";
|
|
const filterMode = config.filterMode ?? "dynamic";
|
|
const autoSelectFirstTable = config.autoSelectFirstTable ?? true;
|
|
const showTableSelector = config.showTableSelector ?? true;
|
|
|
|
// ─── 고정 필터 CRUD ───
|
|
const addFilter = useCallback(() => {
|
|
const newFilter: PresetFilter = {
|
|
id: `filter_${Date.now()}`,
|
|
columnName: "",
|
|
columnLabel: "",
|
|
filterType: "text",
|
|
width: 200,
|
|
};
|
|
const updated = [...localPresetFilters, newFilter];
|
|
setLocalPresetFilters(updated);
|
|
handleChange({ ...config, presetFilters: updated });
|
|
}, [localPresetFilters, handleChange, config]);
|
|
|
|
const removeFilter = useCallback(
|
|
(id: string) => {
|
|
const updated = localPresetFilters.filter((f) => f.id !== id);
|
|
setLocalPresetFilters(updated);
|
|
handleChange({ ...config, presetFilters: updated });
|
|
},
|
|
[localPresetFilters, handleChange, config]
|
|
);
|
|
|
|
const updateFilter = useCallback(
|
|
(id: string, field: keyof PresetFilter, value: any) => {
|
|
const updated = localPresetFilters.map((f) =>
|
|
f.id === id ? { ...f, [field]: value } : f
|
|
);
|
|
setLocalPresetFilters(updated);
|
|
handleChange({ ...config, presetFilters: updated });
|
|
},
|
|
[localPresetFilters, handleChange, config]
|
|
);
|
|
|
|
// 컬럼 선택 시 라벨+타입 자동 설정
|
|
const handleColumnSelect = useCallback(
|
|
(filterId: string, columnName: string) => {
|
|
const selectedColumn = availableColumns.find(
|
|
(col: any) => col.columnName === columnName
|
|
);
|
|
const updated = localPresetFilters.map((f) =>
|
|
f.id === filterId
|
|
? {
|
|
...f,
|
|
columnName,
|
|
columnLabel: selectedColumn?.columnLabel || columnName,
|
|
filterType: getFilterTypeFromInputType(
|
|
selectedColumn?.inputType || "text"
|
|
),
|
|
}
|
|
: f
|
|
);
|
|
setLocalPresetFilters(updated);
|
|
handleChange({ ...config, presetFilters: updated });
|
|
},
|
|
[availableColumns, localPresetFilters, handleChange, config]
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* ─── 1단계: 대상 패널 위치 선택 ─── */}
|
|
<div className="space-y-2">
|
|
<SectionHeader
|
|
icon={Search}
|
|
title="검색 필터 위젯"
|
|
description="화면 내 테이블을 자동 감지하여 검색, 필터, 그룹 기능을 제공합니다"
|
|
/>
|
|
|
|
<p className="text-sm font-medium mt-3">
|
|
어떤 패널의 테이블을 대상으로 하나요?
|
|
</p>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{PANEL_POSITION_CARDS.map((card) => {
|
|
const Icon = card.icon;
|
|
const isSelected = targetPanelPosition === card.value;
|
|
return (
|
|
<button
|
|
key={card.value}
|
|
type="button"
|
|
onClick={() => updateField("targetPanelPosition", card.value)}
|
|
className={cn(
|
|
"flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]",
|
|
isSelected
|
|
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
|
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
|
)}
|
|
>
|
|
<Icon className="h-5 w-5 mb-1.5 text-primary" />
|
|
<span className="text-xs font-medium leading-tight">
|
|
{card.title}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground leading-tight mt-0.5">
|
|
{card.description}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ─── 2단계: 필터 모드 선택 ─── */}
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium">필터를 어떻게 구성할까요?</p>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{FILTER_MODE_CARDS.map((card) => {
|
|
const Icon = card.icon;
|
|
const isSelected = filterMode === card.value;
|
|
return (
|
|
<button
|
|
key={card.value}
|
|
type="button"
|
|
onClick={() => updateField("filterMode", card.value)}
|
|
className={cn(
|
|
"flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]",
|
|
isSelected
|
|
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
|
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
|
)}
|
|
>
|
|
<Icon className="h-5 w-5 mb-1.5 text-primary" />
|
|
<span className="text-xs font-medium leading-tight">
|
|
{card.title}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground leading-tight mt-0.5">
|
|
{card.description}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ─── 3단계: 고정 모드 필터 목록 ─── */}
|
|
{filterMode === "preset" && (
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Filter className="h-4 w-4 text-primary" />
|
|
<span className="text-sm font-medium">고정 필터 목록</span>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={addFilter}
|
|
className="h-7 px-2 text-xs"
|
|
>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
|
|
{localPresetFilters.length === 0 ? (
|
|
<div className="text-center py-6 text-muted-foreground">
|
|
<Filter className="mx-auto mb-2 h-8 w-8 opacity-30" />
|
|
<p className="text-sm">아직 필터가 없어요</p>
|
|
<p className="text-xs">
|
|
위의 추가 버튼으로 필터를 만들어보세요
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{localPresetFilters.map((filter) => (
|
|
<div
|
|
key={filter.id}
|
|
className="bg-card flex flex-col gap-2 rounded-md border px-3 py-2.5"
|
|
>
|
|
{/* 상단: 컬럼 선택 + 삭제 */}
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="flex-1">
|
|
{availableColumns.length > 0 ? (
|
|
<Select
|
|
value={filter.columnName || ""}
|
|
onValueChange={(value) =>
|
|
handleColumnSelect(filter.id, value)
|
|
}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs">
|
|
<SelectValue placeholder="컬럼 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableColumns.map((col: any) => (
|
|
<SelectItem
|
|
key={col.columnName}
|
|
value={col.columnName}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">
|
|
{col.columnLabel}
|
|
</span>
|
|
<span className="text-muted-foreground text-[10px]">
|
|
({col.columnName})
|
|
</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input
|
|
value={filter.columnName}
|
|
onChange={(e) =>
|
|
updateFilter(
|
|
filter.id,
|
|
"columnName",
|
|
e.target.value
|
|
)
|
|
}
|
|
placeholder="예: customer_name"
|
|
className="h-7 text-xs"
|
|
/>
|
|
)}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeFilter(filter.id)}
|
|
className="text-muted-foreground hover:text-destructive h-7 w-7 shrink-0 p-0"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 하단: 필터 타입 + 너비 */}
|
|
<div className="flex items-center gap-1.5">
|
|
<Select
|
|
value={filter.filterType}
|
|
onValueChange={(
|
|
value: "text" | "number" | "date" | "select"
|
|
) => updateFilter(filter.id, "filterType", value)}
|
|
>
|
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{FILTER_TYPE_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-muted-foreground shrink-0 text-[10px]">
|
|
너비
|
|
</span>
|
|
<Input
|
|
type="number"
|
|
value={filter.width || 200}
|
|
onChange={(e) =>
|
|
updateFilter(
|
|
filter.id,
|
|
"width",
|
|
parseInt(e.target.value) || 200
|
|
)
|
|
}
|
|
className="h-7 w-16 text-xs"
|
|
min={100}
|
|
max={500}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 표시명 (컬럼 선택 시 자동 설정, 수동 변경 가능) */}
|
|
{filter.columnLabel && (
|
|
<p className="text-muted-foreground text-[10px]">
|
|
표시명: {filter.columnLabel}
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-[11px] text-muted-foreground">
|
|
고정 모드에서는 설정 버튼이 숨겨지고 지정된 필터만 표시돼요
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 동적 모드 안내 */}
|
|
{filterMode === "dynamic" && (
|
|
<div className="rounded-lg border bg-muted/30 p-4">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Zap className="h-4 w-4 text-primary" />
|
|
<span className="text-sm font-medium">동적 모드 안내</span>
|
|
</div>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
사용자가 테이블 설정 버튼을 클릭하여 원하는 필터를 직접 선택할 수
|
|
있어요. 필터 설정은 브라우저에 저장되어 다음 접속 시에도 유지돼요.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* ─── 4단계: 고급 설정 (기본 접혀있음) ─── */}
|
|
<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-2">
|
|
<SwitchRow
|
|
label="첫 번째 테이블 자동 선택"
|
|
description="화면 로딩 시 대상 패널의 첫 번째 테이블을 자동으로 선택해요"
|
|
checked={autoSelectFirstTable}
|
|
onCheckedChange={(checked) =>
|
|
updateField("autoSelectFirstTable", checked)
|
|
}
|
|
/>
|
|
<SwitchRow
|
|
label="테이블 선택 드롭다운 표시"
|
|
description="여러 테이블이 있을 때 사용자가 직접 대상을 선택할 수 있어요"
|
|
checked={showTableSelector}
|
|
onCheckedChange={(checked) =>
|
|
updateField("showTableSelector", checked)
|
|
}
|
|
/>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
V2TableSearchWidgetConfigPanel.displayName = "V2TableSearchWidgetConfigPanel";
|
|
|
|
export default V2TableSearchWidgetConfigPanel;
|