ERP-node/frontend/components/v2/config-panels/V2TableSearchWidgetConfigPa...

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;