"use client"; /** * V2 피벗 그리드 설정 패널 * 토스식 단계별 UX: 테이블 선택(Combobox) -> 필드 배치(영역 드롭존) -> 고급 설정(Collapsible) */ 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 { Badge } from "@/components/ui/badge"; 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 { Rows, Columns, Calculator, X, Plus, GripVertical, Check, ChevronsUpDown, ChevronDown, ChevronUp, Settings, Database, Info, } from "lucide-react"; import { cn } from "@/lib/utils"; import { tableTypeApi } from "@/lib/api/screen"; import type { PivotGridComponentConfig, PivotFieldConfig, PivotAreaType, AggregationType, FieldDataType, } from "@/lib/registry/components/v2-pivot-grid/types"; interface TableInfo { tableName: string; displayName: string; } interface ColumnInfo { column_name: string; data_type: string; column_comment?: string; } interface V2PivotGridConfigPanelProps { config: PivotGridComponentConfig; onChange: (config: PivotGridComponentConfig) => void; } function mapDbTypeToFieldType(dbType: string): FieldDataType { const type = dbType.toLowerCase(); if (type.includes("int") || type.includes("numeric") || type.includes("decimal") || type.includes("float")) return "number"; if (type.includes("date") || type.includes("time") || type.includes("timestamp")) return "date"; if (type.includes("bool")) return "boolean"; return "string"; } // ─── 영역 드롭존 (토스식) ─── interface AreaDropZoneProps { area: PivotAreaType; label: string; description: string; icon: React.ReactNode; fields: PivotFieldConfig[]; columns: ColumnInfo[]; onAddField: (column: ColumnInfo) => void; onRemoveField: (index: number) => void; onUpdateField: (index: number, updates: Partial) => void; borderClass: string; } const AreaDropZone: React.FC = ({ area, label, description, icon, fields, columns, onAddField, onRemoveField, onUpdateField, borderClass, }) => { const [isExpanded, setIsExpanded] = useState(true); const availableColumns = columns.filter((col) => !fields.some((f) => f.field === col.column_name)); return (
setIsExpanded(!isExpanded)}>
{icon} {label} {fields.length}
{isExpanded ? : }

{description}

{isExpanded && (
{fields.length > 0 ? (
{fields.map((field, idx) => (
{field.caption || field.field} {area === "data" && ( )}
))}
) : (
아래에서 컬럼을 선택하세요
)} {availableColumns.length > 0 && ( )}
)}
); }; // ─── 메인 패널 ─── export const V2PivotGridConfigPanel: React.FC = ({ config, onChange }) => { const [tables, setTables] = useState([]); const [columns, setColumns] = useState([]); const [loadingTables, setLoadingTables] = useState(false); const [loadingColumns, setLoadingColumns] = useState(false); const [tableOpen, setTableOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); useEffect(() => { const loadTables = async () => { setLoadingTables(true); try { const tableList = await tableTypeApi.getTables(); setTables( tableList.map((t: any) => ({ tableName: t.tableName, displayName: t.tableLabel || t.displayName || t.tableName, })) ); } catch { /* ignore */ } finally { setLoadingTables(false); } }; loadTables(); }, []); useEffect(() => { const loadColumns = async () => { if (!config.dataSource?.tableName) { setColumns([]); return; } setLoadingColumns(true); try { const columnList = await tableTypeApi.getColumns(config.dataSource.tableName); setColumns( columnList.map((c: any) => ({ column_name: c.columnName || c.column_name, data_type: c.dataType || c.data_type || "text", column_comment: c.columnLabel || c.column_label || c.columnName || c.column_name, })) ); } catch { /* ignore */ } finally { setLoadingColumns(false); } }; loadColumns(); }, [config.dataSource?.tableName]); const updateConfig = useCallback( (updates: Partial) => { onChange({ ...config, ...updates }); }, [config, onChange] ); const handleAddField = (area: PivotAreaType, column: ColumnInfo) => { const currentFields = config.fields || []; const areaFields = currentFields.filter((f) => f.area === area); const newField: PivotFieldConfig = { field: column.column_name, caption: column.column_comment || column.column_name, area, areaIndex: areaFields.length, dataType: mapDbTypeToFieldType(column.data_type), visible: true, }; if (area === "data") newField.summaryType = "sum"; updateConfig({ fields: [...currentFields, newField] }); }; const handleRemoveField = (area: PivotAreaType, index: number) => { const currentFields = config.fields || []; const newFields = currentFields.filter((f) => !(f.area === area && f.areaIndex === index)); let idx = 0; newFields.forEach((f) => { if (f.area === area) f.areaIndex = idx++; }); updateConfig({ fields: newFields }); }; const handleUpdateField = (area: PivotAreaType, index: number, updates: Partial) => { const currentFields = config.fields || []; const newFields = currentFields.map((f) => f.area === area && f.areaIndex === index ? { ...f, ...updates } : f ); updateConfig({ fields: newFields }); }; const getFieldsByArea = (area: PivotAreaType) => (config.fields || []).filter((f) => f.area === area).sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); const selectedTable = tables.find((t) => t.tableName === config.dataSource?.tableName); return (
{/* ─── 안내 ─── */}

피벗 테이블 설정

  1. 테이블을 선택하세요
  2. 행/열/값 영역에 컬럼을 배치하세요
{/* ─── 1단계: 테이블 선택 ─── */}

테이블 선택

테이블을 찾을 수 없습니다. {tables.map((table) => ( { updateConfig({ dataSource: { ...config.dataSource, type: "table", tableName: table.tableName }, fields: [], }); setTableOpen(false); }} className="text-xs" >
{table.displayName} {table.displayName !== table.tableName && ( {table.tableName} )}
))}

피벗 분석할 데이터가 있는 테이블을 선택해요

{/* ─── 2단계: 필드 배치 ─── */} {config.dataSource?.tableName && (

필드 배치 {loadingColumns && (컬럼 로딩 중...)}

} fields={getFieldsByArea("row")} columns={columns} onAddField={(col) => handleAddField("row", col)} onRemoveField={(idx) => handleRemoveField("row", idx)} onUpdateField={(idx, u) => handleUpdateField("row", idx, u)} borderClass="border-emerald-200 bg-emerald-50/50" /> } fields={getFieldsByArea("column")} columns={columns} onAddField={(col) => handleAddField("column", col)} onRemoveField={(idx) => handleRemoveField("column", idx)} onUpdateField={(idx, u) => handleUpdateField("column", idx, u)} borderClass="border-primary/20 bg-primary/5" /> } fields={getFieldsByArea("data")} columns={columns} onAddField={(col) => handleAddField("data", col)} onRemoveField={(idx) => handleRemoveField("data", idx)} onUpdateField={(idx, u) => handleUpdateField("data", idx, u)} borderClass="border-amber-200 bg-amber-50/50" />
)} {/* ─── 3단계: 고급 설정 (Collapsible) ─── */}
{/* 총계 설정 */}

총계 설정

행 총계 updateConfig({ totals: { ...config.totals, showRowGrandTotals: v } })} />
열 총계 updateConfig({ totals: { ...config.totals, showColumnGrandTotals: v } })} />
행 총계 위치
열 총계 위치
행 소계 updateConfig({ totals: { ...config.totals, showRowTotals: v } })} />
열 소계 updateConfig({ totals: { ...config.totals, showColumnTotals: v } })} />
{/* 스타일 설정 */}

스타일

줄무늬 updateConfig({ style: { ...config.style, alternateRowColors: v } })} />
셀 병합 updateConfig({ style: { ...config.style, mergeCells: v } })} />
CSV 내보내기 updateConfig({ exportConfig: { ...config.exportConfig, excel: v } })} />
상태 저장 updateConfig({ ...(config as any), saveState: v })} />
{/* 크기 설정 */}

크기

높이 updateConfig({ height: e.target.value })} placeholder="400px" className="h-7 text-xs" />
최대 높이 updateConfig({ maxHeight: e.target.value })} placeholder="600px" className="h-7 text-xs" />
{/* 조건부 서식 */}

조건부 서식

{(config.style?.conditionalFormats || []).map((rule, index) => (
{rule.type === "colorScale" && (
{ const newFormats = [...(config.style?.conditionalFormats || [])]; newFormats[index] = { ...rule, colorScale: { ...rule.colorScale, minColor: e.target.value, maxColor: rule.colorScale?.maxColor || "#00ff00" } }; updateConfig({ style: { ...config.style, conditionalFormats: newFormats } }); }} className="h-6 w-6 cursor-pointer rounded" title="최소값 색상" /> { const newFormats = [...(config.style?.conditionalFormats || [])]; newFormats[index] = { ...rule, colorScale: { ...rule.colorScale, minColor: rule.colorScale?.minColor || "#ff0000", maxColor: e.target.value } }; updateConfig({ style: { ...config.style, conditionalFormats: newFormats } }); }} className="h-6 w-6 cursor-pointer rounded" title="최대값 색상" />
)} {rule.type === "dataBar" && ( { const newFormats = [...(config.style?.conditionalFormats || [])]; newFormats[index] = { ...rule, dataBar: { color: e.target.value } }; updateConfig({ style: { ...config.style, conditionalFormats: newFormats } }); }} className="h-6 w-6 cursor-pointer rounded" title="바 색상" /> )} {rule.type === "iconSet" && ( )}
))}
); }; V2PivotGridConfigPanel.displayName = "V2PivotGridConfigPanel"; export default V2PivotGridConfigPanel;