"use client"; /** * V2AggregationWidget 설정 패널 * 토스식 단계별 UX: 데이터 소스(카드) -> 테이블/컴포넌트 선택 -> 집계 항목 -> 필터(접힘) -> 레이아웃(접힘) -> 스타일(접힘) * 기존 AggregationWidgetConfigPanel의 모든 기능을 자체 UI로 완전 구현 */ 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 { Badge } from "@/components/ui/badge"; import { Database, Link2, MousePointer, Table2, Check, ChevronsUpDown, Plus, Trash2, Calculator, Filter, LayoutGrid, Paintbrush, ChevronDown, ChevronRight, } 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; onChange: (config: Partial) => void; screenTableName?: string; screenComponents?: Array<{ id: string; componentType: string; label?: string; tableName?: string; columnName?: string; }>; } export const V2AggregationWidgetConfigPanel: React.FC = ({ config, onChange, screenTableName, screenComponents = [], }) => { // componentConfigChanged 이벤트 발행 래퍼 const handleChange = useCallback((newConfig: Partial) => { onChange(newConfig); if (typeof window !== "undefined") { window.dispatchEvent( new CustomEvent("componentConfigChanged", { detail: { config: { ...config, ...newConfig } }, }) ); } }, [onChange, config]); // ─── 상태 ─── const [columns, setColumns] = useState([]); 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 [itemsOpen, setItemsOpen] = useState(true); const [expandedItemId, setExpandedItemId] = useState(null); 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) => (
{expandedItemId === item.id && (
{/* 컬럼 */}
컬럼
{/* 집계 타입 */}
집계 타입
{/* 표시 라벨 */}
표시 라벨 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" />
); }; V2AggregationWidgetConfigPanel.displayName = "V2AggregationWidgetConfigPanel"; export default V2AggregationWidgetConfigPanel;