diff --git a/frontend/components/v2/config-panels/V2AggregationWidgetConfigPanel.tsx b/frontend/components/v2/config-panels/V2AggregationWidgetConfigPanel.tsx index 0c3e073d..ace13f60 100644 --- a/frontend/components/v2/config-panels/V2AggregationWidgetConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2AggregationWidgetConfigPanel.tsx @@ -2,14 +2,197 @@ /** * V2AggregationWidget 설정 패널 - * 기존 AggregationWidgetConfigPanel의 모든 로직(테이블/컬럼 Combobox, 집계 항목 관리, - * 필터 조건, 데이터 소스 선택, 스타일 설정 등)을 유지하면서 - * componentConfigChanged 이벤트를 추가하여 실시간 업데이트 지원 + * 토스식 단계별 UX: 데이터 소스(카드) -> 테이블/컴포넌트 선택 -> 집계 항목 -> 필터(접힘) -> 레이아웃(접힘) -> 스타일(접힘) + * 기존 AggregationWidgetConfigPanel의 모든 기능을 자체 UI로 완전 구현 */ -import React from "react"; -import { AggregationWidgetConfigPanel } from "@/lib/registry/components/v2-aggregation-widget/AggregationWidgetConfigPanel"; -import type { AggregationWidgetConfig } from "@/lib/registry/components/v2-aggregation-widget/types"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +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 { 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 { + Database, + Link2, + MousePointer, + Table2, + Check, + ChevronsUpDown, + Plus, + Trash2, + Calculator, + Filter, + LayoutGrid, + Paintbrush, + ChevronDown, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import { tableTypeApi } from "@/lib/api/screen"; +import { getCategoryValues } from "@/lib/api/tableCategoryValue"; +import type { + AggregationWidgetConfig, + AggregationItem, + AggregationType, + DataSourceType, + FilterCondition, + FilterOperator, + FilterValueSourceType, +} from "@/lib/registry/components/v2-aggregation-widget/types"; + +// ─── 상수 ─── + +const OPERATOR_LABELS: Record = { + eq: "같음 (=)", + neq: "같지 않음 (!=)", + gt: "보다 큼 (>)", + gte: "크거나 같음 (>=)", + lt: "보다 작음 (<)", + lte: "작거나 같음 (<=)", + like: "포함", + in: "목록에 포함", + isNull: "NULL", + isNotNull: "NOT NULL", +}; + +const VALUE_SOURCE_LABELS: Record = { + static: "고정 값", + formField: "폼 필드", + selection: "선택된 행", + urlParam: "URL 파라미터", +}; + +const SOURCE_CARDS = [ + { value: "table" as DataSourceType, icon: Database, title: "테이블", description: "DB에서 직접 조회" }, + { value: "component" as DataSourceType, icon: Link2, title: "컴포넌트", description: "다른 컴포넌트 연결" }, + { value: "selection" as DataSourceType, icon: MousePointer, title: "선택 데이터", description: "사용자 선택 행" }, +] as const; + +const AGGREGATION_TYPE_OPTIONS = [ + { value: "sum", label: "합계 (SUM)" }, + { value: "avg", label: "평균 (AVG)" }, + { value: "count", label: "개수 (COUNT)" }, + { value: "max", label: "최대 (MAX)" }, + { value: "min", label: "최소 (MIN)" }, +] as const; + +const FORMAT_OPTIONS = [ + { value: "number", label: "숫자" }, + { value: "currency", label: "통화" }, + { value: "percent", label: "퍼센트" }, +] as const; + +// ─── 공통 서브 컴포넌트 ─── + +function SectionHeader({ icon: Icon, title, description }: { + icon: React.ComponentType<{ className?: string }>; + title: string; + description?: string; +}) { + return ( +
+
+ +

{title}

+
+ {description &&

{description}

} +
+ ); +} + +function SwitchRow({ label, description, checked, onCheckedChange }: { + label: string; + description?: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + return ( +
+
+

{label}

+ {description &&

{description}

} +
+ +
+ ); +} + +function LabeledRow({ label, children }: { + label: string; + children: React.ReactNode; +}) { + return ( +
+

{label}

+ {children} +
+ ); +} + +// ─── 카테고리 값 콤보박스 ─── + +function CategoryValueCombobox({ + value, + options, + onChange, + placeholder = "값 선택", +}: { + value: string; + options: Array<{ value: string; label: string }>; + onChange: (value: string) => void; + placeholder?: string; +}) { + const [open, setOpen] = useState(false); + const selectedOption = options.find((opt) => opt.value === value); + + return ( + + + + + + + + + 결과 없음 + + {options.map((opt, index) => ( + { onChange(opt.value); setOpen(false); }} + className="cursor-pointer text-xs" + > + + {opt.label} + + ))} + + + + + + ); +} + +// ─── 메인 컴포넌트 ─── + +interface ColumnInfo { + columnName: string; + label?: string; + dataType?: string; + inputType?: string; + webType?: string; + categoryCode?: string; +} interface V2AggregationWidgetConfigPanelProps { config: AggregationWidgetConfig; @@ -28,11 +211,11 @@ export const V2AggregationWidgetConfigPanel: React.FC { - const handleChange = (newConfig: Partial) => { + // componentConfigChanged 이벤트 발행 래퍼 + const handleChange = useCallback((newConfig: Partial) => { onChange(newConfig); - if (typeof window !== "undefined") { window.dispatchEvent( new CustomEvent("componentConfigChanged", { @@ -40,15 +223,957 @@ export const V2AggregationWidgetConfigPanel: React.FC([]); + const [loadingColumns, setLoadingColumns] = useState(false); + const [availableTables, setAvailableTables] = useState>([]); + const [loadingTables, setLoadingTables] = useState(false); + const [tableComboboxOpen, setTableComboboxOpen] = useState(false); + const [categoryOptionsCache, setCategoryOptionsCache] = useState>>({}); + const [sourceComponentColumnsCache, setSourceComponentColumnsCache] = useState>>({}); + + // Collapsible 상태 + const [filterOpen, setFilterOpen] = useState(false); + const [layoutOpen, setLayoutOpen] = useState(false); + const [styleOpen, setStyleOpen] = useState(false); + + const dataSourceType = config.dataSourceType || "table"; + + // 실제 사용할 테이블 이름 + const targetTableName = useMemo(() => { + if (config.useCustomTable && config.customTableName) return config.customTableName; + return config.tableName || screenTableName; + }, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]); + + // 연결 가능한 컴포넌트 (리피터, 테이블리스트) + const selectableComponents = useMemo(() => { + return screenComponents.filter((comp) => + comp.componentType === "table-list" || + comp.componentType === "v2-table-list" || + comp.componentType === "v2-repeater" || + comp.componentType === "repeat-container" || + comp.componentType === "v2-repeat-container" + ); + }, [screenComponents]); + + // 폼 필드 컴포넌트 + const formFieldComponents = useMemo(() => { + const excludeTypes = [ + "aggregation", "widget", "button", "label", "display", "table-list", + "repeat", "container", "layout", "section", "card", "tabs", "modal", + "flow", "rack", "map", "chart", "image", "file", "media", + ]; + return screenComponents + .filter((comp) => { + const type = comp.componentType?.toLowerCase() || ""; + if (excludeTypes.some((ex) => type.includes(ex))) return false; + const isInput = type.includes("input") || type.includes("select") || type.includes("date") || + type.includes("checkbox") || type.includes("radio") || type.includes("textarea") || + type.includes("number") || type === "v2-input" || type === "v2-select" || + type === "v2-date" || type === "v2-hierarchy"; + return isInput || !!comp.columnName; + }) + .map((comp) => ({ + id: comp.id, + label: comp.label || comp.columnName || comp.id, + columnName: comp.columnName || comp.id, + componentType: comp.componentType, + })); + }, [screenComponents]); + + // 숫자형 컬럼만 + const numericColumns = useMemo(() => { + return columns.filter((col) => { + const inputType = (col.inputType || col.webType || "").toLowerCase(); + return inputType === "number" || inputType === "decimal" || inputType === "integer" || + inputType === "float" || inputType === "currency" || inputType === "percent"; + }); + }, [columns]); + + // ─── 테이블 목록 로드 ─── + 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 (err) { + console.error("테이블 목록 가져오기 실패:", err); + } finally { + setLoadingTables(false); + } + }; + fetchTables(); + }, []); + + // 화면 테이블명 자동 설정 + useEffect(() => { + if (screenTableName && !config.tableName && !config.customTableName) { + handleChange({ tableName: screenTableName }); + } + }, [screenTableName, config.tableName, config.customTableName, handleChange]); + + // ─── 컬럼 로드 ─── + useEffect(() => { + const loadColumns = async () => { + if (!targetTableName) { setColumns([]); return; } + setLoadingColumns(true); + try { + const result = await tableManagementApi.getColumnList(targetTableName); + if (result.success && result.data?.columns) { + const mapped = result.data.columns.map((col: any) => ({ + columnName: col.columnName || col.column_name, + label: col.displayName || col.columnLabel || col.column_label || col.label || col.columnName || col.column_name, + dataType: col.dataType || col.data_type, + inputType: col.inputType || col.input_type, + webType: col.webType || col.web_type, + categoryCode: col.categoryCode || col.category_code, + })); + setColumns(mapped); + const categoryCols = mapped.filter( + (c: ColumnInfo) => c.inputType === "category" || c.webType === "category" + ); + if (categoryCols.length > 0) loadCategoryOptions(categoryCols); + } else { + setColumns([]); + } + } catch (err) { + console.error("컬럼 로드 실패:", err); + setColumns([]); + } finally { + setLoadingColumns(false); + } + }; + loadColumns(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [targetTableName]); + + // 소스 컴포넌트 컬럼 로드 + const loadSourceComponentColumns = useCallback(async (componentId: string) => { + if (sourceComponentColumnsCache[componentId]) return; + const sourceComp = screenComponents.find((c) => c.id === componentId); + if (!sourceComp?.tableName) return; + try { + const response = await tableManagementApi.getColumnList(sourceComp.tableName); + const rawCols = response.data?.columns || (Array.isArray(response.data) ? response.data : []); + const cols = rawCols.map((col: any) => ({ + columnName: col.column_name || col.columnName, + label: col.column_label || col.columnLabel || col.display_name || col.column_name || col.columnName, + })); + setSourceComponentColumnsCache((prev) => ({ ...prev, [componentId]: cols })); + } catch (err) { + console.error("소스 컴포넌트 컬럼 로드 실패:", err); + } + }, [sourceComponentColumnsCache, screenComponents]); + + // 기존 필터의 소스 컴포넌트 컬럼 미리 로드 + useEffect(() => { + (config.filters || []).forEach((filter) => { + if (filter.valueSourceType === "selection" && filter.sourceComponentId) { + loadSourceComponentColumns(filter.sourceComponentId); + } + }); + }, [config.filters, loadSourceComponentColumns]); + + // 카테고리 옵션 로드 + const loadCategoryOptions = useCallback(async (categoryCols: Array<{ columnName: string; categoryCode?: string }>) => { + if (!targetTableName) return; + const newCache: Record> = { ...categoryOptionsCache }; + for (const col of categoryCols) { + const cacheKey = `${targetTableName}_${col.columnName}`; + if (newCache[cacheKey]) continue; + try { + const result = await getCategoryValues(targetTableName, col.columnName, false); + if (result.success && "data" in result && Array.isArray(result.data)) { + const seenCodes = new Set(); + const uniqueOptions: Array<{ value: string; label: string }> = []; + for (const item of result.data) { + const itemAny = item as any; + const code = item.valueCode || itemAny.code || itemAny.value || itemAny.id; + if (!seenCodes.has(code)) { + seenCodes.add(code); + uniqueOptions.push({ + value: code, + label: item.valueLabel || itemAny.valueName || itemAny.name || itemAny.label || itemAny.displayName || code, + }); + } + } + newCache[cacheKey] = uniqueOptions; + } else { + newCache[cacheKey] = []; + } + } catch { + newCache[cacheKey] = []; + } + } + setCategoryOptionsCache(newCache); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [targetTableName]); + + const getCategoryOptionsForColumn = useCallback((columnName: string) => { + if (!targetTableName) return []; + return categoryOptionsCache[`${targetTableName}_${columnName}`] || []; + }, [targetTableName, categoryOptionsCache]); + + const isCategoryColumn = useCallback((columnName: string) => { + const col = columns.find((c) => c.columnName === columnName); + return col?.inputType === "category" || col?.webType === "category"; + }, [columns]); + + // ─── 집계 항목 CRUD ─── + const addItem = useCallback(() => { + const newItem: AggregationItem = { + id: `agg-${Date.now()}`, + columnName: "", + columnLabel: "", + type: "sum", + format: "number", + decimalPlaces: 0, + }; + handleChange({ items: [...(config.items || []), newItem] }); + }, [config.items, handleChange]); + + const removeItem = useCallback((id: string) => { + handleChange({ items: (config.items || []).filter((item) => item.id !== id) }); + }, [config.items, handleChange]); + + const updateItem = useCallback((id: string, updates: Partial) => { + handleChange({ + items: (config.items || []).map((item) => (item.id === id ? { ...item, ...updates } : item)), + }); + }, [config.items, handleChange]); + + // ─── 필터 CRUD ─── + const addFilter = useCallback(() => { + const newFilter: FilterCondition = { + id: `filter-${Date.now()}`, + columnName: "", + operator: "eq", + valueSourceType: "static", + staticValue: "", + enabled: true, + }; + handleChange({ filters: [...(config.filters || []), newFilter] }); + }, [config.filters, handleChange]); + + const removeFilter = useCallback((id: string) => { + handleChange({ filters: (config.filters || []).filter((f) => f.id !== id) }); + }, [config.filters, handleChange]); + + const updateFilter = useCallback((id: string, updates: Partial) => { + handleChange({ + filters: (config.filters || []).map((f) => (f.id === id ? { ...f, ...updates } : f)), + }); + }, [config.filters, handleChange]); + + // ─── 테이블 변경 핸들러 ─── + const handleTableSelect = useCallback((tableName: string, isCustom: boolean) => { + handleChange({ + useCustomTable: isCustom, + customTableName: isCustom ? tableName : undefined, + tableName, + items: [], + filters: [], + }); + setTableComboboxOpen(false); + }, [handleChange]); + + const needsValue = (op: string) => op !== "isNull" && op !== "isNotNull"; + + // ─── 렌더링 ─── return ( - +
+ {/* ═══════════════════════════════════════ */} + {/* 1단계: 데이터 소스 (카드 선택) */} + {/* ═══════════════════════════════════════ */} +
+ + + +
+ {SOURCE_CARDS.map((card) => { + const Icon = card.icon; + const isSelected = dataSourceType === card.value; + return ( + + ); + })} +
+ + {/* ─── table 모드: 테이블 선택 ─── */} + {dataSourceType === "table" && ( +
+ {/* 현재 선택된 테이블 */} +
+ +
+
+ {config.customTableName || config.tableName || screenTableName || "테이블 미선택"} +
+
+ {config.useCustomTable ? "커스텀 테이블" : "화면 기본 테이블"} +
+
+
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + {screenTableName && ( + + handleTableSelect(screenTableName, false)} + className="cursor-pointer text-xs" + > + + + {screenTableName} + + + )} + + {availableTables + .filter((t) => t.tableName !== screenTableName) + .map((table) => ( + handleTableSelect(table.tableName, true)} + className="cursor-pointer text-xs" + > + + + {table.displayName || table.tableName} + + ))} + + + + + +
+ )} + + {/* ─── component 모드: 컴포넌트 연결 ─── */} + {dataSourceType === "component" && ( +
+ 연결할 컴포넌트 + +

리피터 또는 테이블리스트의 데이터를 집계합니다

+
+ )} + + {/* ─── selection 모드: 안내 ─── */} + {dataSourceType === "selection" && ( +
+
+

선택된 행 집계

+

+ 화면에서 사용자가 선택(체크)한 행들만 집계합니다. +

+
+
+ + + {config.customTableName || config.tableName || screenTableName || "테이블 미선택"} + +
+
+ )} +
+ + {/* ═══════════════════════════════════════ */} + {/* 2단계: 집계 항목 */} + {/* ═══════════════════════════════════════ */} +
+
+ + +
+ + + {(config.items || []).length === 0 ? ( +
+ +

아직 집계 항목이 없어요

+

위의 추가 버튼으로 항목을 만들어보세요

+
+ ) : ( +
+ {(config.items || []).map((item, index) => ( +
+
+ 항목 {index + 1} + +
+ +
+ {/* 컬럼 */} +
+ 컬럼 + +
+ + {/* 집계 타입 */} +
+ 집계 타입 + +
+ + {/* 표시 라벨 */} +
+ 표시 라벨 + updateItem(item.id, { columnLabel: e.target.value })} + placeholder="표시될 라벨" + className="h-7 text-xs" + /> +
+ + {/* 표시 형식 */} +
+ 표시 형식 + +
+ + {/* 접두사 */} +
+ 접두사 + updateItem(item.id, { prefix: e.target.value })} + placeholder="예: ₩" + className="h-7 text-xs" + /> +
+ + {/* 접미사 */} +
+ 접미사 + updateItem(item.id, { suffix: e.target.value })} + placeholder="예: 원, 개" + className="h-7 text-xs" + /> +
+
+
+ ))} +
+ )} +
+ + {/* ═══════════════════════════════════════ */} + {/* 3단계: 필터 조건 (접힘) */} + {/* ═══════════════════════════════════════ */} + {(dataSourceType === "table" || dataSourceType === "selection") && ( + + + + + +
+
+

집계 대상을 필터링합니다

+ +
+ + {/* 필터 결합 방식 */} + {(config.filters || []).length > 1 && ( + + + + )} + + {(config.filters || []).length === 0 ? ( +
+ +

필터 없음 - 전체 데이터를 집계합니다

+
+ ) : ( +
+ {(config.filters || []).map((filter, index) => ( +
+
+
+ updateFilter(filter.id, { enabled: checked as boolean })} + className="h-3.5 w-3.5" + /> + 필터 {index + 1} +
+ +
+ +
+ {/* 컬럼 */} +
+ 컬럼 + +
+ + {/* 연산자 */} +
+ 연산자 + +
+ + {/* 값 소스 타입 + 값 입력 */} + {needsValue(filter.operator) && ( + <> +
+ 값 소스 + +
+ +
+ + {filter.valueSourceType === "static" && "값"} + {filter.valueSourceType === "formField" && "폼 필드명"} + {filter.valueSourceType === "selection" && "소스 컬럼"} + {filter.valueSourceType === "urlParam" && "파라미터명"} + + + {filter.valueSourceType === "static" && ( + isCategoryColumn(filter.columnName) ? ( + updateFilter(filter.id, { staticValue: value })} + placeholder="값 선택" + /> + ) : ( + updateFilter(filter.id, { staticValue: e.target.value })} + placeholder="값 입력" + className="h-7 text-xs" + /> + ) + )} + + {filter.valueSourceType === "formField" && ( + formFieldComponents.length > 0 ? ( + + ) : ( +
+ 배치된 입력 필드가 없습니다 +
+ ) + )} + + {filter.valueSourceType === "urlParam" && ( + updateFilter(filter.id, { urlParamName: e.target.value })} + placeholder="파라미터명" + className="h-7 text-xs" + /> + )} +
+ + {/* selection 모드: 소스 컴포넌트 + 소스 컬럼 (2행 사용) */} + {filter.valueSourceType === "selection" && ( + <> +
+ 소스 컴포넌트 + +
+ {filter.sourceComponentId && ( +
+ 소스 컬럼 + +
+ )} + + )} + + )} +
+
+ ))} +
+ )} +
+
+
+ )} + + {/* ═══════════════════════════════════════ */} + {/* 4단계: 레이아웃 설정 (접힘) */} + {/* ═══════════════════════════════════════ */} + + + + + +
+ + + + + + handleChange({ gap: e.target.value })} + placeholder="16px" + className="h-7 w-[100px] text-xs" + /> + + + handleChange({ showLabels: checked })} + /> + + handleChange({ showIcons: checked })} + /> +
+
+
+ + {/* ═══════════════════════════════════════ */} + {/* 5단계: 스타일 설정 (접힘) */} + {/* ═══════════════════════════════════════ */} + + + + + +
+
+
+ 배경색 + handleChange({ backgroundColor: e.target.value })} + className="h-8" + /> +
+
+ 모서리 둥글기 + handleChange({ borderRadius: e.target.value })} + placeholder="6px" + className="h-7 text-xs" + /> +
+
+ 라벨 색상 + handleChange({ labelColor: e.target.value })} + className="h-8" + /> +
+
+ 값 색상 + handleChange({ valueColor: e.target.value })} + className="h-8" + /> +
+
+
+
+
+
); };