"use client"; import React, { useState, useEffect, useMemo } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Badge } from "@/components/ui/badge"; import { Plus, Trash2, GripVertical, Database, Table2, ChevronsUpDown, Check, Filter, Link2, MousePointer } from "lucide-react"; import { cn } from "@/lib/utils"; import { AggregationWidgetConfig, AggregationItem, AggregationType, DataSourceType, FilterCondition, FilterOperator, FilterValueSourceType } from "./types"; import { tableManagementApi } from "@/lib/api/tableManagement"; import { tableTypeApi } from "@/lib/api/screen"; import { getCategoryValues } from "@/lib/api/tableCategoryValue"; interface AggregationWidgetConfigPanelProps { config: AggregationWidgetConfig; onChange: (config: Partial) => void; screenTableName?: string; // 화면 내 컴포넌트 목록 (컴포넌트 연결용) screenComponents?: Array<{ id: string; componentType: string; label?: string; tableName?: string }>; } /** * 카테고리 값 콤보박스 컴포넌트 */ 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="text-xs cursor-pointer" > {opt.label} ))} ); } /** * 집계 위젯 설정 패널 */ // 연산자 라벨 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 파라미터", }; export function AggregationWidgetConfigPanel({ config, onChange, screenTableName, screenComponents = [], }: AggregationWidgetConfigPanelProps) { const [columns, setColumns] = useState>([]); const [loadingColumns, setLoadingColumns] = useState(false); const [availableTables, setAvailableTables] = useState>([]); const [loadingTables, setLoadingTables] = useState(false); const [tableComboboxOpen, setTableComboboxOpen] = useState(false); // 카테고리 옵션 캐시 (categoryCode -> options) const [categoryOptionsCache, setCategoryOptionsCache] = useState>>({}); // 소스 컴포넌트별 컬럼 캐시 (componentId -> columns) const [sourceComponentColumnsCache, setSourceComponentColumnsCache] = useState>>({}); // 데이터 소스 타입 (기본값: table) const dataSourceType = config.dataSourceType || "table"; // 선택 가능한 데이터 소스 컴포넌트 (테이블 리스트 등) const selectableComponents = useMemo(() => { console.log("[AggregationWidget] screenComponents:", screenComponents); const filtered = screenComponents.filter(comp => comp.componentType === "table-list" || comp.componentType === "v2-table-list" || comp.componentType === "unified-repeater" || comp.componentType === "v2-unified-repeater" || comp.componentType === "repeat-container" || comp.componentType === "v2-repeat-container" ); console.log("[AggregationWidget] selectableComponents:", filtered); return filtered; }, [screenComponents]); // 소스 컴포넌트 컬럼 로드 const loadSourceComponentColumns = async (componentId: string) => { // 이미 캐시에 있으면 스킵 if (sourceComponentColumnsCache[componentId]) { return; } const sourceComp = screenComponents.find(c => c.id === componentId); if (!sourceComp?.tableName) { return; } try { const response = await tableManagementApi.getColumns(sourceComp.tableName); const cols = (response.data?.columns || response.data || []).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 (error) { console.error("소스 컴포넌트 컬럼 로드 실패:", error); } }; // 실제 사용할 테이블 이름 계산 const targetTableName = useMemo(() => { if (config.useCustomTable && config.customTableName) { return config.customTableName; } return config.tableName || screenTableName; }, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]); // 화면 테이블명 자동 설정 (초기 한 번만) useEffect(() => { if (screenTableName && !config.tableName && !config.customTableName) { onChange({ tableName: screenTableName }); } }, [screenTableName, config.tableName, config.customTableName, onChange]); // 전체 테이블 목록 로드 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 (error) { console.error("테이블 목록 가져오기 실패:", error); } finally { setLoadingTables(false); } }; fetchTables(); }, []); // 기존 필터의 소스 컴포넌트 컬럼 미리 로드 useEffect(() => { const filters = config.filters || []; filters.forEach((filter) => { if (filter.valueSourceType === "selection" && filter.sourceComponentId) { loadSourceComponentColumns(filter.sourceComponentId); } }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [config.filters, screenComponents]); // 테이블 컬럼 로드 useEffect(() => { const loadColumns = async () => { if (!targetTableName) { setColumns([]); return; } setLoadingColumns(true); try { const result = await tableManagementApi.getColumnList(targetTableName); if (result.success && result.data?.columns) { const mappedColumns = 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(mappedColumns); // 카테고리 타입 컬럼의 옵션 로드 const categoryColumns = mappedColumns.filter( (col: any) => col.inputType === "category" || col.webType === "category" ); if (categoryColumns.length > 0) { loadCategoryOptions(categoryColumns); } } else { setColumns([]); } } catch (error) { console.error("컬럼 로드 실패:", error); setColumns([]); } finally { setLoadingColumns(false); } }; loadColumns(); }, [targetTableName]); // 카테고리 옵션 로드 함수 const loadCategoryOptions = async (categoryColumns: Array<{ columnName: string; categoryCode?: string }>) => { if (!targetTableName) return; const newCache: Record> = { ...categoryOptionsCache }; for (const col of categoryColumns) { const cacheKey = `${targetTableName}_${col.columnName}`; if (newCache[cacheKey]) continue; try { // 카테고리 API 호출 const result = await getCategoryValues(targetTableName, col.columnName, false); if (result.success && Array.isArray(result.data)) { // 중복 제거 (valueCode 기준) const seenCodes = new Set(); const uniqueOptions: Array<{ value: string; label: string }> = []; for (const item of result.data) { const code = item.valueCode || item.code || item.value || item.id; if (!seenCodes.has(code)) { seenCodes.add(code); uniqueOptions.push({ value: code, // valueLabel이 실제 표시명 label: item.valueLabel || item.valueName || item.name || item.label || item.displayName || code, }); } } newCache[cacheKey] = uniqueOptions; } else { newCache[cacheKey] = []; } } catch (error) { console.error(`카테고리 옵션 로드 실패 (${col.columnName}):`, error); // 실패해도 빈 배열로 캐시 newCache[cacheKey] = []; } } setCategoryOptionsCache(newCache); }; // 컬럼의 카테고리 옵션 가져오기 const getCategoryOptionsForColumn = (columnName: string): Array<{ value: string; label: string }> => { if (!targetTableName) return []; const cacheKey = `${targetTableName}_${columnName}`; return categoryOptionsCache[cacheKey] || []; }; // 컬럼이 카테고리 타입인지 확인 const isCategoryColumn = (columnName: string): boolean => { const column = columns.find((c) => c.columnName === columnName); return column?.inputType === "category" || column?.webType === "category"; }; // 집계 항목 추가 const addItem = () => { const newItem: AggregationItem = { id: `agg-${Date.now()}`, columnName: "", columnLabel: "", type: "sum", format: "number", decimalPlaces: 0, }; onChange({ items: [...(config.items || []), newItem], }); }; // 집계 항목 삭제 const removeItem = (id: string) => { onChange({ items: (config.items || []).filter((item) => item.id !== id), }); }; // 집계 항목 업데이트 const updateItem = (id: string, updates: Partial) => { onChange({ items: (config.items || []).map((item) => item.id === id ? { ...item, ...updates } : item ), }); }; // 숫자형 컬럼만 필터링 (count 제외) - 입력 타입(inputType/webType)으로만 확인 const numericColumns = columns.filter((col) => { const inputType = (col.inputType || col.webType || "")?.toLowerCase(); return ( inputType === "number" || inputType === "decimal" || inputType === "integer" || inputType === "float" || inputType === "currency" || inputType === "percent" ); }); // 필터 추가 const addFilter = () => { const newFilter: FilterCondition = { id: `filter-${Date.now()}`, columnName: "", operator: "eq", valueSourceType: "static", staticValue: "", enabled: true, }; onChange({ filters: [...(config.filters || []), newFilter], }); }; // 필터 삭제 const removeFilter = (id: string) => { onChange({ filters: (config.filters || []).filter((f) => f.id !== id), }); }; // 필터 업데이트 const updateFilter = (id: string, updates: Partial) => { onChange({ filters: (config.filters || []).map((f) => f.id === id ? { ...f, ...updates } : f ), }); }; // 연결 가능한 컴포넌트 (리피터, 테이블리스트) const linkableComponents = screenComponents.filter( (c) => c.componentType === "v2-unified-repeater" || c.componentType === "v2-table-list" || c.componentType === "unified-repeater" || c.componentType === "table-list" ); return (
집계 위젯 설정
{/* 데이터 소스 타입 선택 */}

데이터 소스

집계할 데이터를 가져올 방식을 선택합니다


{/* 테이블 선택 (table 타입일 때) */} {dataSourceType === "table" && (
{/* 현재 선택된 테이블 표시 */}
{config.customTableName || config.tableName || screenTableName || "테이블 미선택"}
{config.useCustomTable ? "커스텀 테이블" : "화면 기본 테이블"}
{/* 테이블 선택 Combobox */} 테이블을 찾을 수 없습니다 {/* 그룹 1: 화면 기본 테이블 */} {screenTableName && ( { onChange({ useCustomTable: false, customTableName: undefined, tableName: screenTableName, items: [], filters: [], }); setTableComboboxOpen(false); }} className="text-xs cursor-pointer" > {screenTableName} )} {/* 그룹 2: 전체 테이블 */} {availableTables .filter((table) => table.tableName !== screenTableName) .map((table) => ( { onChange({ useCustomTable: true, customTableName: table.tableName, tableName: table.tableName, items: [], filters: [], }); setTableComboboxOpen(false); }} className="text-xs cursor-pointer" > {table.displayName || table.tableName} ))}
)} {/* 컴포넌트 연결 (component 타입일 때) */} {dataSourceType === "component" && (

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

)} {/* 선택 데이터 설명 (selection 타입일 때) */} {dataSourceType === "selection" && (

선택된 행 집계

화면에서 사용자가 선택(체크)한 행들만 집계합니다. 테이블리스트나 리피터에서 선택된 데이터가 자동으로 집계됩니다.

{/* 테이블 선택 (어느 테이블의 선택 데이터인지) */}
{config.customTableName || config.tableName || screenTableName || "테이블 미선택"}
)}
{/* 필터 조건 (table 또는 selection 타입일 때) */} {(dataSourceType === "table" || dataSourceType === "selection") && (

필터 조건

집계 대상을 필터링합니다


{/* 필터 결합 방식 */} {(config.filters || []).length > 1 && (
)} {/* 필터 목록 */} {(config.filters || []).length === 0 ? (
필터 조건이 없습니다. 전체 데이터를 집계합니다.
) : (
{(config.filters || []).map((filter, index) => (
updateFilter(filter.id, { enabled: checked as boolean })} /> 필터 {index + 1} {filter.columnName && ( {filter.columnName} )}
{/* 컬럼 선택 */}
{/* 연산자 선택 */}
{/* 값 소스 타입 */} {filter.operator !== "isNull" && filter.operator !== "isNotNull" && ( <>
{/* 값 입력 (소스 타입에 따라) */}
{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" && ( updateFilter(filter.id, { formFieldName: e.target.value })} placeholder="필드명 입력" className="h-7 text-xs" /> )} {filter.valueSourceType === "selection" && (
{/* 소스 컴포넌트 선택 */}
{/* 소스 컬럼 선택 */} {filter.sourceComponentId && (
)}
)} {filter.valueSourceType === "urlParam" && ( updateFilter(filter.id, { urlParamName: e.target.value })} placeholder="파라미터명" className="h-7 text-xs" /> )}
)}
))}
)}
)} {/* 레이아웃 설정 */}

레이아웃


onChange({ gap: e.target.value })} placeholder="16px" className="h-8 text-xs" />
onChange({ showLabels: checked as boolean })} />
onChange({ showIcons: checked as boolean })} />
{/* 집계 항목 설정 */}

집계 항목


{(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" />
))}
)}
{/* 스타일 설정 */}

스타일


onChange({ backgroundColor: e.target.value })} className="h-8" />
onChange({ borderRadius: e.target.value })} placeholder="6px" className="h-8 text-xs" />
onChange({ labelColor: e.target.value })} className="h-8" />
onChange({ valueColor: e.target.value })} className="h-8" />
); }