"use client"; /** * V2 피벗 그리드 설정 패널 * 토스식 단계별 UX: 테이블 선택(Combobox) -> 필드 배치(AreaDropZone) -> 고급 설정(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, ConditionalFormatRule, } 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; color: string; } const AreaDropZone: React.FC = ({ area, label, description, icon, fields, columns, onAddField, onRemoveField, onUpdateField, color, }) => { 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 && ( )}
)}
); }; const STYLE_DEFAULTS: { theme: "default"; headerStyle: "default"; cellPadding: "normal"; borderStyle: "light" } = { theme: "default", headerStyle: "default", cellPadding: "normal", borderStyle: "light", }; /* ─── 메인 컴포넌트 ─── */ 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(() => { if (!config.dataSource?.tableName) { setColumns([]); return; } const loadColumns = async () => { 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) => { const newConfig = { ...config, ...updates }; onChange(newConfig); if (typeof window !== "undefined") { window.dispatchEvent( new CustomEvent("componentConfigChanged", { detail: { config: newConfig }, }) ); } }, [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 handleTableChange = (tableName: string) => { updateConfig({ dataSource: { ...config.dataSource, type: "table", tableName }, fields: [], }); setTableOpen(false); }; return (
{/* ─── 안내 ─── */}

피벗 테이블 설정 방법

  1. 데이터를 가져올 테이블을 선택
  2. 행 그룹에 그룹화 컬럼 추가 (예: 지역, 부서)
  3. 열 그룹에 가로 펼칠 컬럼 추가 (예: 월, 분기)
  4. 에 집계할 숫자 컬럼 추가 (예: 매출, 수량)
{/* ─── 1단계: 테이블 선택 ─── */}

테이블 선택

피벗 분석에 사용할 데이터 테이블을 골라요

테이블을 찾을 수 없습니다. {tables.map((table) => ( handleTableChange(table.tableName)} 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, updates) => handleUpdateField("row", idx, updates)} color="border-emerald-200 bg-emerald-50/50" /> } fields={getFieldsByArea("column")} columns={columns} onAddField={(col) => handleAddField("column", col)} onRemoveField={(idx) => handleRemoveField("column", idx)} onUpdateField={(idx, updates) => handleUpdateField("column", idx, updates)} color="border-primary/20 bg-primary/5" /> } fields={getFieldsByArea("data")} columns={columns} onAddField={(col) => handleAddField("data", col)} onRemoveField={(idx) => handleRemoveField("data", idx)} onUpdateField={(idx, updates) => handleUpdateField("data", idx, updates)} color="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: { ...STYLE_DEFAULTS, ...config.style, alternateRowColors: v } }) } />

셀 병합

같은 값을 가진 인접 셀을 병합해요

updateConfig({ style: { ...STYLE_DEFAULTS, ...config.style, mergeCells: 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" />
{/* 기능 설정 */}

기능 설정

CSV 내보내기

데이터를 CSV 파일로 내보낼 수 있어요

updateConfig({ exportConfig: { ...config.exportConfig, excel: v } }) } />

전체 확장/축소

모든 그룹을 한번에 열거나 닫을 수 있어요

updateConfig({ allowExpandAll: v })} />

필터링

필드별 필터를 사용할 수 있어요

updateConfig({ allowFiltering: v })} />

요약값 기준 정렬

집계 결과를 클릭해서 정렬할 수 있어요

updateConfig({ allowSortingBySummary: v })} />

텍스트 줄바꿈

긴 텍스트를 셀 안에서 줄바꿈해요

updateConfig({ wordWrapEnabled: v })} />
{/* 조건부 서식 */}

조건부 서식

{(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: { ...STYLE_DEFAULTS, ...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: { ...STYLE_DEFAULTS, ...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: { ...STYLE_DEFAULTS, ...config.style, conditionalFormats: newFormats } }); }} className="h-6 w-6 cursor-pointer rounded" title="바 색상" /> )} {rule.type === "iconSet" && ( )}
))}
); }; export default V2PivotGridConfigPanel;