[agent-pipeline] pipe-20260311182531-f443 round-1
This commit is contained in:
parent
3c2c1764fc
commit
5baf5842b4
|
|
@ -2,66 +2,561 @@
|
|||
|
||||
/**
|
||||
* V2TableSearchWidget 설정 패널
|
||||
* 기존 TableSearchWidgetConfigPanel의 모든 로직(필터 모드, 대상 패널, 고정 필터 등)을 유지하면서
|
||||
* componentConfigChanged 이벤트를 추가하여 실시간 업데이트 지원
|
||||
* 토스식 단계별 UX: 대상 패널 카드 선택 -> 필터 모드 카드 선택 -> 고정 필터 목록 -> 고급 설정(접힘)
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { TableSearchWidgetConfigPanel } from "@/lib/registry/components/v2-table-search-widget/TableSearchWidgetConfigPanel";
|
||||
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 {
|
||||
component?: any;
|
||||
config?: any;
|
||||
onUpdateProperty?: (property: string, value: any) => void;
|
||||
onChange?: (newConfig: any) => void;
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
tables?: any[];
|
||||
}
|
||||
|
||||
export function V2TableSearchWidgetConfigPanel({
|
||||
component,
|
||||
config,
|
||||
onUpdateProperty,
|
||||
onChange,
|
||||
tables,
|
||||
}: V2TableSearchWidgetConfigPanelProps) {
|
||||
const handleChange = (newConfig: any) => {
|
||||
if (onChange) {
|
||||
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]
|
||||
);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("componentConfigChanged", {
|
||||
detail: { config: newConfig },
|
||||
})
|
||||
// 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 handleUpdateProperty = (property: string, value: any) => {
|
||||
if (onUpdateProperty) {
|
||||
onUpdateProperty(property, value);
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("componentConfigChanged", {
|
||||
detail: { property, value },
|
||||
})
|
||||
// 컬럼 선택 시 라벨+타입 자동 설정
|
||||
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 (
|
||||
<TableSearchWidgetConfigPanel
|
||||
component={component}
|
||||
config={config}
|
||||
onUpdateProperty={onChange ? undefined : handleUpdateProperty}
|
||||
onChange={onChange ? handleChange : undefined}
|
||||
tables={tables}
|
||||
/>
|
||||
<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";
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue