diff --git a/.playwright-mcp/pivotgrid-demo.png b/.playwright-mcp/pivotgrid-demo.png new file mode 100644 index 00000000..0fad6fa6 Binary files /dev/null and b/.playwright-mcp/pivotgrid-demo.png differ diff --git a/.playwright-mcp/pivotgrid-table.png b/.playwright-mcp/pivotgrid-table.png new file mode 100644 index 00000000..79041f47 Binary files /dev/null and b/.playwright-mcp/pivotgrid-table.png differ diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index e28e1755..9ca202ed 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -88,6 +88,9 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // λ‚΄λΆ€ 인 // πŸ†• μ—°κ΄€ 데이터 λ²„νŠΌ μ»΄ν¬λ„ŒνŠΈ import "./related-data-buttons/RelatedDataButtonsRenderer"; // 쒌츑 선택 데이터 기반 μ—°κ΄€ ν…Œμ΄λΈ” λ²„νŠΌ ν‘œμ‹œ +// πŸ†• ν”Όλ²— κ·Έλ¦¬λ“œ μ»΄ν¬λ„ŒνŠΈ +import "./pivot-grid/PivotGridRenderer"; // 닀차원 데이터 뢄석 ν”Όλ²— ν…Œμ΄λΈ” + /** * μ»΄ν¬λ„ŒνŠΈ μ΄ˆκΈ°ν™” ν•¨μˆ˜ */ diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx new file mode 100644 index 00000000..b81057a3 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -0,0 +1,644 @@ +"use client"; + +/** + * PivotGrid 메인 μ»΄ν¬λ„ŒνŠΈ + * 닀차원 데이터 뢄석을 μœ„ν•œ ν”Όλ²— ν…Œμ΄λΈ” + */ + +import React, { useState, useMemo, useCallback } from "react"; +import { cn } from "@/lib/utils"; +import { + PivotGridProps, + PivotResult, + PivotFieldConfig, + PivotCellData, + PivotFlatRow, + PivotCellValue, + PivotGridState, +} from "./types"; +import { processPivotData, pathToKey } from "./utils/pivotEngine"; +import { + ChevronRight, + ChevronDown, + Download, + Settings, + RefreshCw, + Maximize2, + Minimize2, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; + +// ==================== μ„œλΈŒ μ»΄ν¬λ„ŒνŠΈ ==================== + +// ν–‰ 헀더 μ…€ +interface RowHeaderCellProps { + row: PivotFlatRow; + rowFields: PivotFieldConfig[]; + onToggleExpand: (path: string[]) => void; +} + +const RowHeaderCell: React.FC = ({ + row, + rowFields, + onToggleExpand, +}) => { + const indentSize = row.level * 20; + + return ( + +
+ {row.hasChildren && ( + + )} + {!row.hasChildren && } + {row.caption} +
+ + ); +}; + +// 데이터 μ…€ +interface DataCellProps { + values: PivotCellValue[]; + isTotal?: boolean; + onClick?: () => void; +} + +const DataCell: React.FC = ({ + values, + isTotal = false, + onClick, +}) => { + if (!values || values.length === 0) { + return ( + + - + + ); + } + + // 단일 데이터 ν•„λ“œμΈ 경우 + if (values.length === 1) { + return ( + + {values[0].formattedValue} + + ); + } + + // 닀쀑 데이터 ν•„λ“œμΈ 경우 + return ( + <> + {values.map((val, idx) => ( + + {val.formattedValue} + + ))} + + ); +}; + +// ==================== 메인 μ»΄ν¬λ„ŒνŠΈ ==================== + +export const PivotGridComponent: React.FC = ({ + title, + fields = [], + totals = { + showRowGrandTotals: true, + showColumnGrandTotals: true, + showRowTotals: true, + showColumnTotals: true, + }, + style = { + theme: "default", + headerStyle: "default", + cellPadding: "normal", + borderStyle: "light", + alternateRowColors: true, + highlightTotals: true, + }, + allowExpandAll = true, + height = "auto", + maxHeight, + exportConfig, + data: externalData, + onCellClick, + onExpandChange, +}) => { + // ==================== μƒνƒœ ==================== + + const [pivotState, setPivotState] = useState({ + expandedRowPaths: [], + expandedColumnPaths: [], + sortConfig: null, + filterConfig: {}, + }); + + const [isFullscreen, setIsFullscreen] = useState(false); + + // 데이터 + const data = externalData || []; + + // ==================== ν•„λ“œ λΆ„λ₯˜ ==================== + + const rowFields = useMemo( + () => + fields + .filter((f) => f.area === "row" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), + [fields] + ); + + const columnFields = useMemo( + () => + fields + .filter((f) => f.area === "column" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), + [fields] + ); + + const dataFields = useMemo( + () => + fields + .filter((f) => f.area === "data" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), + [fields] + ); + + // ==================== ν”Όλ²— 처리 ==================== + + const pivotResult = useMemo(() => { + if (!data || data.length === 0 || fields.length === 0) { + return null; + } + + return processPivotData( + data, + fields, + pivotState.expandedRowPaths, + pivotState.expandedColumnPaths + ); + }, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); + + // ==================== 이벀트 ν•Έλ“€λŸ¬ ==================== + + // ν–‰ ν™•μž₯/μΆ•μ†Œ + const handleToggleRowExpand = useCallback( + (path: string[]) => { + setPivotState((prev) => { + const pathKey = pathToKey(path); + const existingIndex = prev.expandedRowPaths.findIndex( + (p) => pathToKey(p) === pathKey + ); + + let newPaths: string[][]; + if (existingIndex >= 0) { + newPaths = prev.expandedRowPaths.filter( + (_, i) => i !== existingIndex + ); + } else { + newPaths = [...prev.expandedRowPaths, path]; + } + + onExpandChange?.(newPaths); + + return { + ...prev, + expandedRowPaths: newPaths, + }; + }); + }, + [onExpandChange] + ); + + // 전체 ν™•μž₯ + const handleExpandAll = useCallback(() => { + if (!pivotResult) return; + + const allRowPaths: string[][] = []; + + pivotResult.flatRows.forEach((row) => { + if (row.hasChildren) { + allRowPaths.push(row.path); + } + }); + + setPivotState((prev) => ({ + ...prev, + expandedRowPaths: allRowPaths, + expandedColumnPaths: [], + })); + }, [pivotResult]); + + // 전체 μΆ•μ†Œ + const handleCollapseAll = useCallback(() => { + setPivotState((prev) => ({ + ...prev, + expandedRowPaths: [], + expandedColumnPaths: [], + })); + }, []); + + // μ…€ 클릭 + const handleCellClick = useCallback( + (rowPath: string[], colPath: string[], values: PivotCellValue[]) => { + if (!onCellClick) return; + + const cellData: PivotCellData = { + value: values[0]?.value, + rowPath, + columnPath: colPath, + field: values[0]?.field, + }; + + onCellClick(cellData); + }, + [onCellClick] + ); + + // CSV 내보내기 + const handleExportCSV = useCallback(() => { + if (!pivotResult) return; + + const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; + + let csv = ""; + + // 헀더 ν–‰ + const headerRow = [""].concat( + flatColumns.map((col) => col.caption || "총계") + ); + if (totals?.showRowGrandTotals) { + headerRow.push("총계"); + } + csv += headerRow.join(",") + "\n"; + + // 데이터 ν–‰ + flatRows.forEach((row) => { + const rowData = [row.caption]; + + flatColumns.forEach((col) => { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey); + rowData.push(values?.[0]?.value?.toString() || ""); + }); + + if (totals?.showRowGrandTotals) { + const rowTotal = grandTotals.row.get(pathToKey(row.path)); + rowData.push(rowTotal?.[0]?.value?.toString() || ""); + } + + csv += rowData.join(",") + "\n"; + }); + + // μ—΄ 총계 ν–‰ + if (totals?.showColumnGrandTotals) { + const totalRow = ["총계"]; + flatColumns.forEach((col) => { + const colTotal = grandTotals.column.get(pathToKey(col.path)); + totalRow.push(colTotal?.[0]?.value?.toString() || ""); + }); + if (totals?.showRowGrandTotals) { + totalRow.push(grandTotals.grand[0]?.value?.toString() || ""); + } + csv += totalRow.join(",") + "\n"; + } + + // λ‹€μš΄λ‘œλ“œ + const blob = new Blob(["\uFEFF" + csv], { + type: "text/csv;charset=utf-8;", + }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = `${title || "pivot"}_export.csv`; + link.click(); + }, [pivotResult, totals, title]); + + // ==================== λ Œλ”λ§ ==================== + + // 빈 μƒνƒœ + if (!data || data.length === 0) { + return ( +
+ +

데이터가 μ—†μŠ΅λ‹ˆλ‹€

+

데이터λ₯Ό λ‘œλ“œν•˜κ±°λ‚˜ ν•„λ“œλ₯Ό μ„€μ •ν•΄μ£Όμ„Έμš”

+
+ ); + } + + // ν•„λ“œ λ―Έμ„€μ • + if (fields.length === 0) { + return ( +
+ +

ν•„λ“œκ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€

+

+ ν–‰, μ—΄, 데이터 μ˜μ—­μ— ν•„λ“œλ₯Ό λ°°μΉ˜ν•΄μ£Όμ„Έμš” +

+
+ ); + } + + // ν”Όλ²— κ²°κ³Ό μ—†μŒ + if (!pivotResult) { + return ( +
+ +
+ ); + } + + const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; + + return ( +
+ {/* 헀더 νˆ΄λ°” */} +
+
+ {title &&

{title}

} + + ({data.length}건) + +
+ +
+ {allowExpandAll && ( + <> + + + + + )} + + {exportConfig?.excel && ( + + )} + + +
+
+ + {/* ν”Όλ²— ν…Œμ΄λΈ” */} +
+ + + {/* μ—΄ 헀더 */} + + {/* μ’Œμƒλ‹¨ μ½”λ„ˆ (ν–‰ ν•„λ“œ 라벨) */} + + + {/* μ—΄ 헀더 μ…€ */} + {flatColumns.map((col, idx) => ( + + ))} + + {/* ν–‰ 총계 헀더 */} + {totals?.showRowGrandTotals && ( + + )} + + + {/* 데이터 ν•„λ“œ 라벨 (닀쀑 데이터 ν•„λ“œμΈ 경우) */} + {dataFields.length > 1 && ( + + {flatColumns.map((col, colIdx) => ( + + {dataFields.map((df, dfIdx) => ( + + ))} + + ))} + {totals?.showRowGrandTotals && + dataFields.map((df, dfIdx) => ( + + ))} + + )} + + + + {flatRows.map((row, rowIdx) => ( + + {/* ν–‰ 헀더 */} + + + {/* 데이터 μ…€ */} + {flatColumns.map((col, colIdx) => { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey) || []; + + return ( + handleCellClick(row.path, col.path, values) + : undefined + } + /> + ); + })} + + {/* ν–‰ 총계 */} + {totals?.showRowGrandTotals && ( + + )} + + ))} + + {/* μ—΄ 총계 ν–‰ */} + {totals?.showColumnGrandTotals && ( + + + + {flatColumns.map((col, colIdx) => ( + + ))} + + {/* λŒ€μ΄ν•© */} + {totals?.showRowGrandTotals && ( + + )} + + )} + +
0 ? 2 : 1} + > + {rowFields.map((f) => f.caption).join(" / ") || "ν•­λͺ©"} + + {col.caption || "(전체)"} + + 총계 +
+ {df.caption} + + {df.caption} +
+ 총계 +
+
+
+ ); +}; + +export default PivotGridComponent; diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx new file mode 100644 index 00000000..a0e322d9 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx @@ -0,0 +1,751 @@ +"use client"; + +/** + * PivotGrid μ„€μ • νŒ¨λ„ + * ν™”λ©΄ κ΄€λ¦¬μ—μ„œ PivotGrid μ»΄ν¬λ„ŒνŠΈλ₯Ό μ„€μ •ν•˜λŠ” UI + */ + +import React, { useState, useEffect, useCallback } from "react"; +import { cn } from "@/lib/utils"; +import { + PivotGridComponentConfig, + PivotFieldConfig, + PivotAreaType, + AggregationType, + DateGroupInterval, + FieldDataType, +} from "./types"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { + Plus, + Trash2, + GripVertical, + Settings2, + Rows, + Columns, + Database, + Filter, + ChevronUp, + ChevronDown, +} from "lucide-react"; +import { apiClient } from "@/lib/api/client"; + +// ==================== νƒ€μž… ==================== + +interface TableInfo { + table_name: string; + table_comment?: string; +} + +interface ColumnInfo { + column_name: string; + data_type: string; + column_comment?: string; + is_nullable: string; +} + +interface PivotGridConfigPanelProps { + config: PivotGridComponentConfig; + onChange: (config: PivotGridComponentConfig) => void; +} + +// ==================== μœ ν‹Έλ¦¬ν‹° ==================== + +const AREA_LABELS: Record = { + row: { label: "ν–‰ μ˜μ—­", icon: }, + column: { label: "μ—΄ μ˜μ—­", icon: }, + data: { label: "데이터 μ˜μ—­", icon: }, + filter: { label: "ν•„ν„° μ˜μ—­", icon: }, +}; + +const AGGREGATION_OPTIONS: { value: AggregationType; label: string }[] = [ + { value: "sum", label: "합계" }, + { value: "count", label: "개수" }, + { value: "avg", label: "평균" }, + { value: "min", label: "μ΅œμ†Œ" }, + { value: "max", label: "μ΅œλŒ€" }, + { value: "countDistinct", label: "κ³ μœ κ°’ 개수" }, +]; + +const DATE_GROUP_OPTIONS: { value: DateGroupInterval; label: string }[] = [ + { value: "year", label: "연도" }, + { value: "quarter", label: "λΆ„κΈ°" }, + { value: "month", label: "μ›”" }, + { value: "week", label: "μ£Ό" }, + { value: "day", label: "일" }, +]; + +const DATA_TYPE_OPTIONS: { value: FieldDataType; label: string }[] = [ + { value: "string", label: "λ¬Έμžμ—΄" }, + { value: "number", label: "숫자" }, + { value: "date", label: "λ‚ μ§œ" }, + { value: "boolean", label: "λΆ€μšΈ" }, +]; + +// DB νƒ€μž…μ„ FieldDataType으둜 λ³€ν™˜ +function mapDbTypeToFieldType(dbType: string): FieldDataType { + const type = dbType.toLowerCase(); + if ( + type.includes("int") || + type.includes("numeric") || + type.includes("decimal") || + type.includes("float") || + type.includes("double") || + type.includes("real") + ) { + return "number"; + } + if ( + type.includes("date") || + type.includes("time") || + type.includes("timestamp") + ) { + return "date"; + } + if (type.includes("bool")) { + return "boolean"; + } + return "string"; +} + +// ==================== ν•„λ“œ μ„€μ • μ»΄ν¬λ„ŒνŠΈ ==================== + +interface FieldConfigItemProps { + field: PivotFieldConfig; + index: number; + onChange: (field: PivotFieldConfig) => void; + onRemove: () => void; + onMoveUp: () => void; + onMoveDown: () => void; + isFirst: boolean; + isLast: boolean; +} + +const FieldConfigItem: React.FC = ({ + field, + index, + onChange, + onRemove, + onMoveUp, + onMoveDown, + isFirst, + isLast, +}) => { + return ( +
+ {/* λ“œλž˜κ·Έ ν•Έλ“€ & μˆœμ„œ λ²„νŠΌ */} +
+ + + +
+ + {/* ν•„λ“œ μ„€μ • */} +
+ {/* ν•„λ“œλͺ… & 라벨 */} +
+
+ + onChange({ ...field, field: e.target.value })} + placeholder="column_name" + className="h-8 text-xs" + /> +
+
+ + onChange({ ...field, caption: e.target.value })} + placeholder="ν‘œμ‹œλͺ…" + className="h-8 text-xs" + /> +
+
+ + {/* 데이터 νƒ€μž… & 집계 ν•¨μˆ˜ */} +
+
+ + +
+ + {field.area === "data" && ( +
+ + +
+ )} + + {field.dataType === "date" && + (field.area === "row" || field.area === "column") && ( +
+ + +
+ )} +
+
+ + {/* μ‚­μ œ λ²„νŠΌ */} + +
+ ); +}; + +// ==================== μ˜μ—­λ³„ ν•„λ“œ λͺ©λ‘ ==================== + +interface AreaFieldListProps { + area: PivotAreaType; + fields: PivotFieldConfig[]; + allColumns: ColumnInfo[]; + onFieldsChange: (fields: PivotFieldConfig[]) => void; +} + +const AreaFieldList: React.FC = ({ + area, + fields, + allColumns, + onFieldsChange, +}) => { + const areaFields = fields.filter((f) => f.area === area); + const { label, icon } = AREA_LABELS[area]; + + const handleAddField = () => { + const newField: PivotFieldConfig = { + field: "", + caption: "", + area, + areaIndex: areaFields.length, + dataType: "string", + visible: true, + }; + if (area === "data") { + newField.summaryType = "sum"; + } + onFieldsChange([...fields, newField]); + }; + + const handleAddFromColumn = (column: ColumnInfo) => { + const dataType = mapDbTypeToFieldType(column.data_type); + const newField: PivotFieldConfig = { + field: column.column_name, + caption: column.column_comment || column.column_name, + area, + areaIndex: areaFields.length, + dataType, + visible: true, + }; + if (area === "data") { + newField.summaryType = "sum"; + } + onFieldsChange([...fields, newField]); + }; + + const handleFieldChange = (index: number, updatedField: PivotFieldConfig) => { + const newFields = [...fields]; + const globalIndex = fields.findIndex( + (f) => f.area === area && f.areaIndex === index + ); + if (globalIndex >= 0) { + newFields[globalIndex] = updatedField; + onFieldsChange(newFields); + } + }; + + const handleRemoveField = (index: number) => { + const newFields = fields.filter( + (f) => !(f.area === area && f.areaIndex === index) + ); + // 인덱슀 μž¬μ •λ ¬ + let idx = 0; + newFields.forEach((f) => { + if (f.area === area) { + f.areaIndex = idx++; + } + }); + onFieldsChange(newFields); + }; + + const handleMoveField = (fromIndex: number, direction: "up" | "down") => { + const toIndex = direction === "up" ? fromIndex - 1 : fromIndex + 1; + if (toIndex < 0 || toIndex >= areaFields.length) return; + + const newAreaFields = [...areaFields]; + const [moved] = newAreaFields.splice(fromIndex, 1); + newAreaFields.splice(toIndex, 0, moved); + + // 인덱슀 μž¬μ •λ ¬ + newAreaFields.forEach((f, idx) => { + f.areaIndex = idx; + }); + + // 전체 ν•„λ“œ μ—…λ°μ΄νŠΈ + const newFields = fields.filter((f) => f.area !== area); + onFieldsChange([...newFields, ...newAreaFields]); + }; + + // 이미 μΆ”κ°€λœ 컬럼 μ œμ™Έ + const availableColumns = allColumns.filter( + (col) => !fields.some((f) => f.field === col.column_name) + ); + + return ( + + +
+ {icon} + {label} + + {areaFields.length} + +
+
+ + {/* ν•„λ“œ λͺ©λ‘ */} + {areaFields + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)) + .map((field, idx) => ( + handleFieldChange(field.areaIndex || idx, f)} + onRemove={() => handleRemoveField(field.areaIndex || idx)} + onMoveUp={() => handleMoveField(idx, "up")} + onMoveDown={() => handleMoveField(idx, "down")} + isFirst={idx === 0} + isLast={idx === areaFields.length - 1} + /> + ))} + + {/* ν•„λ“œ μΆ”κ°€ */} +
+ + + +
+
+
+ ); +}; + +// ==================== 메인 μ»΄ν¬λ„ŒνŠΈ ==================== + +export const PivotGridConfigPanel: React.FC = ({ + config, + onChange, +}) => { + const [tables, setTables] = useState([]); + const [columns, setColumns] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingColumns, setLoadingColumns] = useState(false); + + // ν…Œμ΄λΈ” λͺ©λ‘ λ‘œλ“œ + useEffect(() => { + const loadTables = async () => { + setLoadingTables(true); + try { + const response = await apiClient.get("/api/table-management/list"); + if (response.data.success) { + setTables(response.data.data || []); + } + } catch (error) { + console.error("ν…Œμ΄λΈ” λͺ©λ‘ λ‘œλ“œ μ‹€νŒ¨:", error); + } finally { + setLoadingTables(false); + } + }; + loadTables(); + }, []); + + // ν…Œμ΄λΈ” 선택 μ‹œ 컬럼 λ‘œλ“œ + useEffect(() => { + const loadColumns = async () => { + if (!config.dataSource?.tableName) { + setColumns([]); + return; + } + + setLoadingColumns(true); + try { + const response = await apiClient.get( + `/api/table-management/columns/${config.dataSource.tableName}` + ); + if (response.data.success) { + setColumns(response.data.data || []); + } + } catch (error) { + console.error("컬럼 λͺ©λ‘ λ‘œλ“œ μ‹€νŒ¨:", error); + } finally { + setLoadingColumns(false); + } + }; + loadColumns(); + }, [config.dataSource?.tableName]); + + // μ„€μ • μ—…λ°μ΄νŠΈ 헬퍼 + const updateConfig = useCallback( + (updates: Partial) => { + onChange({ ...config, ...updates }); + }, + [config, onChange] + ); + + return ( +
+ {/* 데이터 μ†ŒμŠ€ μ„€μ • */} +
+ + +
+ + +
+
+ + + + {/* ν•„λ“œ μ„€μ • */} + {config.dataSource?.tableName && ( +
+
+ + + {columns.length}개 컬럼 + +
+ + {loadingColumns ? ( +
+ 컬럼 λ‘œλ”© 쀑... +
+ ) : ( + + {(["row", "column", "data", "filter"] as PivotAreaType[]).map( + (area) => ( + updateConfig({ fields })} + /> + ) + )} + + )} +
+ )} + + + + {/* ν‘œμ‹œ μ„€μ • */} +
+ + +
+
+ + + 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, highlightTotals: v }, + }) + } + /> +
+
+ + + + {/* κΈ°λŠ₯ μ„€μ • */} +
+ + +
+
+ + + updateConfig({ allowExpandAll: v }) + } + /> +
+ +
+ + + updateConfig({ + exportConfig: { ...config.exportConfig, excel: v }, + }) + } + /> +
+
+
+ + + + {/* 크기 μ„€μ • */} +
+ + +
+
+ + updateConfig({ height: e.target.value })} + placeholder="auto λ˜λŠ” 400px" + className="h-8 text-xs" + /> +
+ +
+ + updateConfig({ maxHeight: e.target.value })} + placeholder="600px" + className="h-8 text-xs" + /> +
+
+
+
+ ); +}; + +export default PivotGridConfigPanel; + diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx new file mode 100644 index 00000000..826ec1db --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx @@ -0,0 +1,246 @@ +"use client"; + +/** + * PivotGrid λ Œλ”λŸ¬ + * ν™”λ©΄ 관리 μ‹œμŠ€ν…œμ—μ„œ PivotGridλ₯Ό λ Œλ”λ§ν•˜λŠ” μ»΄ν¬λ„ŒνŠΈ + */ + +import React, { useState, useEffect, useMemo } from "react"; +import { ComponentRegistry } from "../../ComponentRegistry"; +import { PivotGridComponent } from "./PivotGridComponent"; +import { PivotGridConfigPanel } from "./PivotGridConfigPanel"; +import { + PivotGridComponentConfig, + PivotFieldConfig, + PivotCellData, +} from "./types"; +import { apiClient } from "@/lib/api/client"; + +// ==================== νƒ€μž… ==================== + +interface PivotGridRendererProps { + // μœ„μ ― ID + id?: string; + + // μ»΄ν¬λ„ŒνŠΈ μ„€μ • + config?: PivotGridComponentConfig; + + // μ™ΈλΆ€ 데이터 (formData λ“±μ—μ„œ μ£Όμž…) + data?: Record[]; + + // ν™”λ©΄ 관리 μ»¨ν…μŠ€νŠΈ + formData?: Record; + + // 이벀트 ν•Έλ“€λŸ¬ + onCellClick?: (cellData: PivotCellData) => void; + onDataLoad?: (data: Record[]) => void; + + // μ œμ–΄κ΄€λ¦¬ 연동 + buttonControlOptions?: { + buttonId?: string; + actionType?: string; + }; + + // μžλ™ ν•„ν„° (λ©€ν‹°ν…Œλ„Œμ‹œ) + autoFilter?: { + companyCode?: string; + }; +} + +// ==================== 메인 μ»΄ν¬λ„ŒνŠΈ ==================== + +export const PivotGridRenderer: React.FC = ({ + id, + config, + data: externalData, + formData, + onCellClick, + onDataLoad, + buttonControlOptions, + autoFilter, +}) => { + const [data, setData] = useState[]>([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // 데이터 λ‘œλ“œ + useEffect(() => { + const loadData = async () => { + // μ™ΈλΆ€ 데이터가 있으면 μ‚¬μš© + if (externalData && externalData.length > 0) { + setData(externalData); + onDataLoad?.(externalData); + return; + } + + // 데이터 μ†ŒμŠ€ μ„€μ • 확인 + if (!config?.dataSource?.tableName) { + setData([]); + return; + } + + setLoading(true); + setError(null); + + try { + // ν…Œμ΄λΈ” 데이터 쑰회 + const params: any = { + tableName: config.dataSource.tableName, + }; + + // λ©€ν‹°ν…Œλ„Œμ‹œ ν•„ν„° 적용 + if (autoFilter?.companyCode) { + params.companyCode = autoFilter.companyCode; + } + + // ν•„ν„° 쑰건 적용 + if (config.dataSource.filterConditions) { + const filters: Record = {}; + config.dataSource.filterConditions.forEach((cond) => { + if (cond.valueFromField && formData) { + filters[cond.field] = formData[cond.valueFromField]; + } else if (cond.value !== undefined) { + filters[cond.field] = cond.value; + } + }); + params.filters = JSON.stringify(filters); + } + + const response = await apiClient.get( + `/api/table-management/data/${config.dataSource.tableName}`, + { params } + ); + + if (response.data.success) { + const loadedData = response.data.data || []; + setData(loadedData); + onDataLoad?.(loadedData); + } else { + throw new Error(response.data.message || "데이터 λ‘œλ“œ μ‹€νŒ¨"); + } + } catch (err: any) { + console.error("PivotGrid 데이터 λ‘œλ“œ μ‹€νŒ¨:", err); + setError(err.message || "데이터λ₯Ό λΆˆλŸ¬μ˜€λŠ”λ° μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€"); + setData([]); + } finally { + setLoading(false); + } + }; + + loadData(); + }, [ + config?.dataSource?.tableName, + config?.dataSource?.filterConditions, + externalData, + formData, + autoFilter?.companyCode, + onDataLoad, + ]); + + // ν•„λ“œ μ„€μ •μ—μ„œ formData κ°’ 적용 + const processedFields = useMemo(() => { + if (!config?.fields) return []; + + return config.fields.map((field) => { + // ν•„ν„° 값에 formData 적용 + if (field.filterValues && formData) { + return { + ...field, + filterValues: field.filterValues.map((v) => { + if (typeof v === "string" && v.startsWith("{{") && v.endsWith("}}")) { + const key = v.slice(2, -2).trim(); + return formData[key] ?? v; + } + return v; + }), + }; + } + return field; + }); + }, [config?.fields, formData]); + + // λ‘œλ”© μƒνƒœ + if (loading) { + return ( +
+
+
+ 데이터 λ‘œλ”© 쀑... +
+
+ ); + } + + // μ—λŸ¬ μƒνƒœ + if (error) { + return ( +
+
+

데이터 λ‘œλ“œ μ‹€νŒ¨

+

{error}

+
+
+ ); + } + + return ( + + ); +}; + +// ==================== μ»΄ν¬λ„ŒνŠΈ 등둝 ==================== + +ComponentRegistry.register({ + type: "pivot-grid", + label: "ν”Όλ²— κ·Έλ¦¬λ“œ", + category: "data", + icon: "BarChart3", + description: "닀차원 데이터 뢄석을 μœ„ν•œ ν”Όλ²— ν…Œμ΄λΈ” μ»΄ν¬λ„ŒνŠΈ", + defaultConfig: { + dataSource: { + type: "table", + tableName: "", + }, + fields: [], + totals: { + showRowGrandTotals: true, + showColumnGrandTotals: true, + showRowTotals: true, + showColumnTotals: true, + }, + style: { + theme: "default", + headerStyle: "default", + cellPadding: "normal", + borderStyle: "light", + alternateRowColors: true, + highlightTotals: true, + }, + allowExpandAll: true, + exportConfig: { + excel: true, + }, + height: "400px", + }, + Renderer: PivotGridRenderer, + ConfigPanel: PivotGridConfigPanel, +}); + +export default PivotGridRenderer; diff --git a/frontend/lib/registry/components/pivot-grid/README.md b/frontend/lib/registry/components/pivot-grid/README.md new file mode 100644 index 00000000..6ce42532 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/README.md @@ -0,0 +1,238 @@ +# PivotGrid μ»΄ν¬λ„ŒνŠΈ + +닀차원 데이터 뢄석을 μœ„ν•œ ν”Όλ²— ν…Œμ΄λΈ” μ»΄ν¬λ„ŒνŠΈμž…λ‹ˆλ‹€. + +## μ£Όμš” κΈ°λŠ₯ + +### 1. 닀차원 데이터 배치 + +- **ν–‰ μ˜μ—­(Row Area)**: 데이터λ₯Ό ν–‰μœΌλ‘œ κ·Έλ£Ήν™” (예: μ§€μ—­ β†’ λ„μ‹œ) +- **μ—΄ μ˜μ—­(Column Area)**: 데이터λ₯Ό μ—΄λ‘œ κ·Έλ£Ήν™” (예: 연도 β†’ λΆ„κΈ°) +- **데이터 μ˜μ—­(Data Area)**: 집계될 수치 ν•„λ“œ (예: λ§€μΆœμ•‘, μˆ˜λŸ‰) +- **ν•„ν„° μ˜μ—­(Filter Area)**: 전체 데이터 필터링 + +### 2. 집계 ν•¨μˆ˜ + +| ν•¨μˆ˜ | μ„€λͺ… | μ‚¬μš© 예 | +|------|------|---------| +| `sum` | 합계 | 맀좜 합계 | +| `count` | 개수 | 건수 | +| `avg` | 평균 | 평균 단가 | +| `min` | μ΅œμ†Œκ°’ | μ΅œμ €κ°€ | +| `max` | μ΅œλŒ€κ°’ | μ΅œκ³ κ°€ | +| `countDistinct` | κ³ μœ κ°’ 개수 | 거래처 수 | + +### 3. λ‚ μ§œ κ·Έλ£Ήν™” + +λ‚ μ§œ ν•„λ“œλ₯Ό λ‹€μ–‘ν•œ λ‹¨μœ„λ‘œ κ·Έλ£Ήν™”ν•  수 μžˆμŠ΅λ‹ˆλ‹€: + +- `year`: 연도별 +- `quarter`: 뢄기별 +- `month`: 월별 +- `week`: 주별 +- `day`: 일별 + +### 4. λ“œλ¦΄λ‹€μš΄ + +계측적 데이터λ₯Ό ν™•μž₯/μΆ•μ†Œν•˜μ—¬ 상세 λ‚΄μš©μ„ λ³Ό 수 μžˆμŠ΅λ‹ˆλ‹€. + +### 5. 총합계/μ†Œκ³„ + +- ν–‰ 총합계 (Row Grand Total) +- μ—΄ 총합계 (Column Grand Total) +- ν–‰ μ†Œκ³„ (Row Subtotal) +- μ—΄ μ†Œκ³„ (Column Subtotal) + +### 6. 내보내기 + +CSV ν˜•μ‹μœΌλ‘œ 데이터λ₯Ό 내보낼 수 μžˆμŠ΅λ‹ˆλ‹€. + +## μ‚¬μš©λ²• + +### κΈ°λ³Έ μ‚¬μš© + +```tsx +import { PivotGridComponent } from "@/lib/registry/components/pivot-grid"; + +const salesData = [ + { region: "뢁미", city: "λ‰΄μš•", year: 2024, quarter: "Q1", amount: 15000 }, + { region: "뢁미", city: "LA", year: 2024, quarter: "Q1", amount: 12000 }, + // ... +]; + + +``` + +### λ‚ μ§œ κ·Έλ£Ήν™” + +```tsx + +``` + +### 포맷 μ„€μ • + +```tsx + +``` + +### ν™”λ©΄ κ΄€λ¦¬μ—μ„œ μ‚¬μš© + +μ„€μ • νŒ¨λ„μ„ 톡해 ν…Œμ΄λΈ” 선택, ν•„λ“œ 배치, 집계 ν•¨μˆ˜ 등을 GUI둜 μ„€μ •ν•  수 μžˆμŠ΅λ‹ˆλ‹€. + +```tsx +import { PivotGridRenderer } from "@/lib/registry/components/pivot-grid"; + + +``` + +## μ„€μ • μ˜΅μ…˜ + +### PivotGridProps + +| 속성 | νƒ€μž… | κΈ°λ³Έκ°’ | μ„€λͺ… | +|------|------|--------|------| +| `title` | `string` | - | ν”Όλ²— ν…Œμ΄λΈ” 제λͺ© | +| `data` | `any[]` | `[]` | 원본 데이터 λ°°μ—΄ | +| `fields` | `PivotFieldConfig[]` | `[]` | ν•„λ“œ μ„€μ • λͺ©λ‘ | +| `totals` | `PivotTotalsConfig` | - | 총합계/μ†Œκ³„ ν‘œμ‹œ μ„€μ • | +| `style` | `PivotStyleConfig` | - | μŠ€νƒ€μΌ μ„€μ • | +| `allowExpandAll` | `boolean` | `true` | 전체 ν™•μž₯/μΆ•μ†Œ λ²„νŠΌ | +| `exportConfig` | `PivotExportConfig` | - | 내보내기 μ„€μ • | +| `height` | `string | number` | `"auto"` | 높이 | +| `maxHeight` | `string` | - | μ΅œλŒ€ 높이 | + +### PivotFieldConfig + +| 속성 | νƒ€μž… | ν•„μˆ˜ | μ„€λͺ… | +|------|------|------|------| +| `field` | `string` | O | 데이터 ν•„λ“œλͺ… | +| `caption` | `string` | O | ν‘œμ‹œ 라벨 | +| `area` | `"row" | "column" | "data" | "filter"` | O | 배치 μ˜μ—­ | +| `areaIndex` | `number` | - | μ˜μ—­ λ‚΄ μˆœμ„œ | +| `dataType` | `"string" | "number" | "date" | "boolean"` | - | 데이터 νƒ€μž… | +| `summaryType` | `AggregationType` | - | 집계 ν•¨μˆ˜ (data μ˜μ—­) | +| `groupInterval` | `DateGroupInterval` | - | λ‚ μ§œ κ·Έλ£Ή λ‹¨μœ„ | +| `format` | `PivotFieldFormat` | - | κ°’ 포맷 | +| `visible` | `boolean` | - | ν‘œμ‹œ μ—¬λΆ€ | + +### PivotTotalsConfig + +| 속성 | νƒ€μž… | κΈ°λ³Έκ°’ | μ„€λͺ… | +|------|------|--------|------| +| `showRowGrandTotals` | `boolean` | `true` | ν–‰ 총합계 ν‘œμ‹œ | +| `showColumnGrandTotals` | `boolean` | `true` | μ—΄ 총합계 ν‘œμ‹œ | +| `showRowTotals` | `boolean` | `true` | ν–‰ μ†Œκ³„ ν‘œμ‹œ | +| `showColumnTotals` | `boolean` | `true` | μ—΄ μ†Œκ³„ ν‘œμ‹œ | + +## 파일 ꡬ쑰 + +``` +pivot-grid/ +β”œβ”€β”€ index.ts # λͺ¨λ“ˆ μ§„μž…μ  +β”œβ”€β”€ types.ts # νƒ€μž… μ •μ˜ +β”œβ”€β”€ PivotGridComponent.tsx # 메인 μ»΄ν¬λ„ŒνŠΈ +β”œβ”€β”€ PivotGridRenderer.tsx # ν™”λ©΄ 관리 λ Œλ”λŸ¬ +β”œβ”€β”€ PivotGridConfigPanel.tsx # μ„€μ • νŒ¨λ„ +β”œβ”€β”€ README.md # λ¬Έμ„œ +└── utils/ + β”œβ”€β”€ index.ts # μœ ν‹Έλ¦¬ν‹° λͺ¨λ“ˆ μ§„μž…μ  + β”œβ”€β”€ aggregation.ts # 집계 ν•¨μˆ˜ + └── pivotEngine.ts # ν”Όλ²— 데이터 처리 μ—”μ§„ +``` + +## μ‚¬μš© μ‹œλ‚˜λ¦¬μ˜€ + +### 1. 맀좜 뢄석 + +지역별/기간별/μ œν’ˆλ³„ 맀좜 ν˜„ν™©μ„ λΆ„μ„ν•©λ‹ˆλ‹€. + +### 2. 재고 ν˜„ν™© + +창고별/ν’ˆλͺ©λ³„ 재고 μˆ˜λŸ‰μ„ ν•œλˆˆμ— νŒŒμ•…ν•©λ‹ˆλ‹€. + +### 3. 생산 싀적 + +생산라인별/μΌμžλ³„ μƒμ‚°λŸ‰μ„ λΆ„μ„ν•©λ‹ˆλ‹€. + +### 4. λΉ„μš© 뢄석 + +λΆ€μ„œλ³„/계정별 λΉ„μš©μ„ μ§‘κ³„ν•˜μ—¬ λΆ„μ„ν•©λ‹ˆλ‹€. + +### 5. 수주 ν˜„ν™© + +κ±°λž˜μ²˜λ³„/ν’ˆλͺ©λ³„/월별 수주 ν˜„ν™©μ„ λΆ„μ„ν•©λ‹ˆλ‹€. + +## μ£Όμ˜μ‚¬ν•­ + +1. **λŒ€λŸ‰ 데이터**: 데이터가 λ§Žμ„ 경우 μ„±λŠ₯에 영ν–₯을 쀄 수 μžˆμŠ΅λ‹ˆλ‹€. μ μ ˆν•œ 필터링을 μ‚¬μš©ν•˜μ„Έμš”. +2. **λ©€ν‹°ν…Œλ„Œμ‹œ**: `autoFilter.companyCode`λ₯Ό 톡해 νšŒμ‚¬λ³„ 데이터 격리가 μ μš©λ©λ‹ˆλ‹€. +3. **ν•„λ“œ μˆœμ„œ**: `areaIndex`λ₯Ό 톡해 μ˜μ—­ λ‚΄ ν•„λ“œ μˆœμ„œλ₯Ό μ§€μ •ν•˜μ„Έμš”. + diff --git a/frontend/lib/registry/components/pivot-grid/index.ts b/frontend/lib/registry/components/pivot-grid/index.ts new file mode 100644 index 00000000..821815bf --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/index.ts @@ -0,0 +1,63 @@ +/** + * PivotGrid μ»΄ν¬λ„ŒνŠΈ λͺ¨λ“ˆ + * 닀차원 데이터 뢄석을 μœ„ν•œ ν”Όλ²— ν…Œμ΄λΈ” + */ + +// 메인 μ»΄ν¬λ„ŒνŠΈ +export { PivotGridComponent } from "./PivotGridComponent"; +export { PivotGridRenderer } from "./PivotGridRenderer"; +export { PivotGridConfigPanel } from "./PivotGridConfigPanel"; + +// νƒ€μž… +export type { + // κΈ°λ³Έ νƒ€μž… + PivotAreaType, + AggregationType, + SortDirection, + DateGroupInterval, + FieldDataType, + DataSourceType, + // ν•„λ“œ μ„€μ • + PivotFieldFormat, + PivotFieldConfig, + // 데이터 μ†ŒμŠ€ + PivotFilterCondition, + PivotJoinConfig, + PivotDataSourceConfig, + // ν‘œμ‹œ μ„€μ • + PivotTotalsConfig, + FieldChooserConfig, + PivotChartConfig, + PivotStyleConfig, + PivotExportConfig, + // Props + PivotGridProps, + // κ²°κ³Ό 데이터 + PivotCellData, + PivotHeaderNode, + PivotCellValue, + PivotResult, + PivotFlatRow, + PivotFlatColumn, + // μƒνƒœ + PivotGridState, + // Config + PivotGridComponentConfig, +} from "./types"; + +// μœ ν‹Έλ¦¬ν‹° +export { + aggregate, + sum, + count, + avg, + min, + max, + countDistinct, + formatNumber, + formatDate, + getAggregationLabel, +} from "./utils/aggregation"; + +export { processPivotData, pathToKey, keyToPath } from "./utils/pivotEngine"; + diff --git a/frontend/lib/registry/components/pivot-grid/types.ts b/frontend/lib/registry/components/pivot-grid/types.ts new file mode 100644 index 00000000..c7f30186 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/types.ts @@ -0,0 +1,345 @@ +/** + * PivotGrid μ»΄ν¬λ„ŒνŠΈ νƒ€μž… μ •μ˜ + * 닀차원 데이터 뢄석을 μœ„ν•œ ν”Όλ²— ν…Œμ΄λΈ” μ»΄ν¬λ„ŒνŠΈ + */ + +// ==================== κΈ°λ³Έ νƒ€μž… ==================== + +// ν•„λ“œ μ˜μ—­ νƒ€μž… +export type PivotAreaType = "row" | "column" | "data" | "filter"; + +// 집계 ν•¨μˆ˜ νƒ€μž… +export type AggregationType = "sum" | "count" | "avg" | "min" | "max" | "countDistinct"; + +// μ •λ ¬ λ°©ν–₯ +export type SortDirection = "asc" | "desc" | "none"; + +// λ‚ μ§œ κ·Έλ£Ή 간격 +export type DateGroupInterval = "year" | "quarter" | "month" | "week" | "day"; + +// ν•„λ“œ 데이터 νƒ€μž… +export type FieldDataType = "string" | "number" | "date" | "boolean"; + +// 데이터 μ†ŒμŠ€ νƒ€μž… +export type DataSourceType = "table" | "api" | "static"; + +// ==================== ν•„λ“œ μ„€μ • ==================== + +// ν•„λ“œ 포맷 μ„€μ • +export interface PivotFieldFormat { + type: "number" | "currency" | "percent" | "date" | "text"; + precision?: number; // μ†Œμˆ˜μ  자릿수 + thousandSeparator?: boolean; // μ²œλ‹¨μœ„ κ΅¬λΆ„μž + prefix?: string; // 접두사 (예: "$", "β‚©") + suffix?: string; // 접미사 (예: "%", "원") + dateFormat?: string; // λ‚ μ§œ ν˜•μ‹ (예: "YYYY-MM-DD") +} + +// ν•„λ“œ μ„€μ • +export interface PivotFieldConfig { + // κΈ°λ³Έ 정보 + field: string; // 데이터 ν•„λ“œλͺ… + caption: string; // ν‘œμ‹œ 라벨 + area: PivotAreaType; // 배치 μ˜μ—­ + areaIndex?: number; // μ˜μ—­ λ‚΄ μˆœμ„œ + + // 데이터 νƒ€μž… + dataType?: FieldDataType; // 데이터 νƒ€μž… + + // 집계 μ„€μ • (data μ˜μ—­μš©) + summaryType?: AggregationType; // 집계 ν•¨μˆ˜ + + // μ •λ ¬ μ„€μ • + sortBy?: "value" | "caption"; // μ •λ ¬ κΈ°μ€€ + sortOrder?: SortDirection; // μ •λ ¬ λ°©ν–₯ + sortBySummary?: string; // μš”μ•½κ°’ κΈ°μ€€ μ •λ ¬ (data ν•„λ“œλͺ…) + + // λ‚ μ§œ κ·Έλ£Ήν™” μ„€μ • + groupInterval?: DateGroupInterval; // λ‚ μ§œ κ·Έλ£Ή 간격 + groupName?: string; // κ·Έλ£Ή 이름 (같은 그룹끼리 계측 ν˜•μ„±) + + // ν‘œμ‹œ μ„€μ • + visible?: boolean; // ν‘œμ‹œ μ—¬λΆ€ + width?: number; // 컬럼 λ„ˆλΉ„ + expanded?: boolean; // κΈ°λ³Έ ν™•μž₯ μƒνƒœ + + // 포맷 μ„€μ • + format?: PivotFieldFormat; // κ°’ 포맷 + + // ν•„ν„° μ„€μ • + filterValues?: any[]; // μ„ νƒλœ ν•„ν„° κ°’ + filterType?: "include" | "exclude"; // ν•„ν„° νƒ€μž… + allowFiltering?: boolean; // 필터링 ν—ˆμš© + allowSorting?: boolean; // μ •λ ¬ ν—ˆμš© + + // 계측 κ΄€λ ¨ + displayFolder?: string; // ν•„λ“œ μ„ νƒκΈ°μ—μ„œ 폴더 ꡬ쑰 + isMeasure?: boolean; // μΈ‘μ •κ°’ μ „μš© ν•„λ“œ (data μ˜μ—­λ§Œ κ°€λŠ₯) +} + +// ==================== 데이터 μ†ŒμŠ€ μ„€μ • ==================== + +// ν•„ν„° 쑰건 +export interface PivotFilterCondition { + field: string; + operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN"; + value?: any; + valueFromField?: string; // formDataμ—μ„œ κ°’ κ°€μ Έμ˜€κΈ° +} + +// 쑰인 μ„€μ • +export interface PivotJoinConfig { + joinType: "INNER" | "LEFT" | "RIGHT"; + targetTable: string; + sourceColumn: string; + targetColumn: string; + columns: string[]; // κ°€μ Έμ˜¬ μ»¬λŸΌλ“€ +} + +// 데이터 μ†ŒμŠ€ μ„€μ • +export interface PivotDataSourceConfig { + type: DataSourceType; + + // ν…Œμ΄λΈ” 기반 + tableName?: string; // ν…Œμ΄λΈ”λͺ… + + // API 기반 + apiEndpoint?: string; // API μ—”λ“œν¬μΈνŠΈ + apiMethod?: "GET" | "POST"; // HTTP λ©”μ„œλ“œ + + // 정적 데이터 + staticData?: any[]; // 정적 데이터 + + // ν•„ν„° 쑰건 + filterConditions?: PivotFilterCondition[]; + + // 쑰인 μ„€μ • + joinConfigs?: PivotJoinConfig[]; +} + +// ==================== ν‘œμ‹œ μ„€μ • ==================== + +// 총합계 ν‘œμ‹œ μ„€μ • +export interface PivotTotalsConfig { + // ν–‰ 총합계 + showRowGrandTotals?: boolean; // ν–‰ 총합계 ν‘œμ‹œ + showRowTotals?: boolean; // ν–‰ μ†Œκ³„ ν‘œμ‹œ + rowTotalsPosition?: "first" | "last"; // μ†Œκ³„ μœ„μΉ˜ + + // μ—΄ 총합계 + showColumnGrandTotals?: boolean; // μ—΄ 총합계 ν‘œμ‹œ + showColumnTotals?: boolean; // μ—΄ μ†Œκ³„ ν‘œμ‹œ + columnTotalsPosition?: "first" | "last"; // μ†Œκ³„ μœ„μΉ˜ +} + +// ν•„λ“œ 선택기 μ„€μ • +export interface FieldChooserConfig { + enabled: boolean; // ν™œμ„±ν™” μ—¬λΆ€ + allowSearch?: boolean; // 검색 ν—ˆμš© + layout?: "default" | "simplified"; // λ ˆμ΄μ•„μ›ƒ + height?: number; // 높이 + applyChangesMode?: "instantly" | "onDemand"; // λ³€κ²½ 적용 μ‹œμ  +} + +// 차트 연동 μ„€μ • +export interface PivotChartConfig { + enabled: boolean; // 차트 ν‘œμ‹œ μ—¬λΆ€ + type: "bar" | "line" | "area" | "pie" | "stackedBar"; + position: "top" | "bottom" | "left" | "right"; + height?: number; + showLegend?: boolean; + animate?: boolean; +} + +// μŠ€νƒ€μΌ μ„€μ • +export interface PivotStyleConfig { + theme: "default" | "compact" | "modern"; + headerStyle: "default" | "dark" | "light"; + cellPadding: "compact" | "normal" | "comfortable"; + borderStyle: "none" | "light" | "heavy"; + alternateRowColors?: boolean; + highlightTotals?: boolean; // 총합계 κ°•μ‘° +} + +// ==================== 내보내기 μ„€μ • ==================== + +export interface PivotExportConfig { + excel?: boolean; + pdf?: boolean; + fileName?: string; +} + +// ==================== 메인 Props ==================== + +export interface PivotGridProps { + // κΈ°λ³Έ μ„€μ • + id?: string; + title?: string; + + // 데이터 μ†ŒμŠ€ + dataSource?: PivotDataSourceConfig; + + // ν•„λ“œ μ„€μ • + fields?: PivotFieldConfig[]; + + // ν‘œμ‹œ μ„€μ • + totals?: PivotTotalsConfig; + style?: PivotStyleConfig; + + // ν•„λ“œ 선택기 + fieldChooser?: FieldChooserConfig; + + // 차트 연동 + chart?: PivotChartConfig; + + // κΈ°λŠ₯ μ„€μ • + allowSortingBySummary?: boolean; // μš”μ•½κ°’ κΈ°μ€€ μ •λ ¬ + allowFiltering?: boolean; // 필터링 ν—ˆμš© + allowExpandAll?: boolean; // 전체 ν™•μž₯/μΆ•μ†Œ ν—ˆμš© + wordWrapEnabled?: boolean; // ν…μŠ€νŠΈ μ€„λ°”κΏˆ + + // 크기 μ„€μ • + height?: string | number; + maxHeight?: string; + + // μƒνƒœ μ €μž₯ + stateStoring?: { + enabled: boolean; + storageKey?: string; // localStorage ν‚€ + }; + + // 내보내기 + exportConfig?: PivotExportConfig; + + // 데이터 (μ™ΈλΆ€ μ£Όμž…μš©) + data?: any[]; + + // 이벀트 + onCellClick?: (cellData: PivotCellData) => void; + onCellDoubleClick?: (cellData: PivotCellData) => void; + onFieldDrop?: (field: PivotFieldConfig, targetArea: PivotAreaType) => void; + onExpandChange?: (expandedPaths: string[][]) => void; + onDataChange?: (data: any[]) => void; +} + +// ==================== κ²°κ³Ό 데이터 ꡬ쑰 ==================== + +// μ…€ 데이터 +export interface PivotCellData { + value: any; // μ…€ κ°’ + rowPath: string[]; // ν–‰ 경둜 (예: ["뢁미", "λ‰΄μš•"]) + columnPath: string[]; // μ—΄ 경둜 (예: ["2024", "Q1"]) + field?: string; // 데이터 ν•„λ“œλͺ… + aggregationType?: AggregationType; + isTotal?: boolean; // 총합계 μ—¬λΆ€ + isGrandTotal?: boolean; // λŒ€μ΄ν•© μ—¬λΆ€ +} + +// 헀더 λ…Έλ“œ (트리 ꡬ쑰) +export interface PivotHeaderNode { + value: any; // 원본 κ°’ + caption: string; // ν‘œμ‹œ ν…μŠ€νŠΈ + level: number; // 깊이 + children?: PivotHeaderNode[]; // μžμ‹ λ…Έλ“œ + isExpanded: boolean; // ν™•μž₯ μƒνƒœ + path: string[]; // 경둜 (λ“œλ¦΄λ‹€μš΄μš©) + subtotal?: PivotCellValue[]; // μ†Œκ³„ + span?: number; // colspan/rowspan +} + +// μ…€ κ°’ +export interface PivotCellValue { + field: string; // 데이터 ν•„λ“œ + value: number | null; // 집계 κ°’ + formattedValue: string; // 포맷된 κ°’ +} + +// ν”Όλ²— κ²°κ³Ό 데이터 ꡬ쑰 +export interface PivotResult { + // ν–‰ 헀더 트리 + rowHeaders: PivotHeaderNode[]; + + // μ—΄ 헀더 트리 + columnHeaders: PivotHeaderNode[]; + + // 데이터 맀트릭슀 (rowPath + columnPath β†’ values) + dataMatrix: Map; + + // ν”Œλž« ν–‰ λͺ©λ‘ (λ Œλ”λ§μš©) + flatRows: PivotFlatRow[]; + + // ν”Œλž« μ—΄ λͺ©λ‘ (λ Œλ”λ§μš©) + flatColumns: PivotFlatColumn[]; + + // 총합계 + grandTotals: { + row: Map; // 행별 총합 + column: Map; // 열별 총합 + grand: PivotCellValue[]; // λŒ€μ΄ν•© + }; +} + +// ν”Œλž« ν–‰ (λ Œλ”λ§μš©) +export interface PivotFlatRow { + path: string[]; + level: number; + caption: string; + isExpanded: boolean; + hasChildren: boolean; + isTotal?: boolean; +} + +// ν”Œλž« μ—΄ (λ Œλ”λ§μš©) +export interface PivotFlatColumn { + path: string[]; + level: number; + caption: string; + span: number; + isTotal?: boolean; +} + +// ==================== μƒνƒœ 관리 ==================== + +export interface PivotGridState { + expandedRowPaths: string[][]; // ν™•μž₯된 ν–‰ κ²½λ‘œλ“€ + expandedColumnPaths: string[][]; // ν™•μž₯된 μ—΄ κ²½λ‘œλ“€ + sortConfig: { + field: string; + direction: SortDirection; + } | null; + filterConfig: Record; // ν•„λ“œλ³„ ν•„ν„°κ°’ +} + +// ==================== μ»΄ν¬λ„ŒνŠΈ Config (ν™”λ©΄κ΄€λ¦¬μš©) ==================== + +export interface PivotGridComponentConfig { + // 데이터 μ†ŒμŠ€ + dataSource?: PivotDataSourceConfig; + + // ν•„λ“œ μ„€μ • + fields?: PivotFieldConfig[]; + + // ν‘œμ‹œ μ„€μ • + totals?: PivotTotalsConfig; + style?: PivotStyleConfig; + + // ν•„λ“œ 선택기 + fieldChooser?: FieldChooserConfig; + + // 차트 연동 + chart?: PivotChartConfig; + + // κΈ°λŠ₯ μ„€μ • + allowSortingBySummary?: boolean; + allowFiltering?: boolean; + allowExpandAll?: boolean; + wordWrapEnabled?: boolean; + + // 크기 μ„€μ • + height?: string | number; + maxHeight?: string; + + // 내보내기 + exportConfig?: PivotExportConfig; +} + diff --git a/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts b/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts new file mode 100644 index 00000000..f0e5302b --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts @@ -0,0 +1,175 @@ +/** + * PivotGrid 집계 ν•¨μˆ˜ μœ ν‹Έλ¦¬ν‹° + * λ‹€μ–‘ν•œ 집계 연산을 μˆ˜ν–‰ν•©λ‹ˆλ‹€. + */ + +import { AggregationType, PivotFieldFormat } from "../types"; + +// ==================== 집계 ν•¨μˆ˜ ==================== + +/** + * 합계 계산 + */ +export function sum(values: number[]): number { + return values.reduce((acc, val) => acc + (val || 0), 0); +} + +/** + * 개수 계산 + */ +export function count(values: any[]): number { + return values.length; +} + +/** + * 평균 계산 + */ +export function avg(values: number[]): number { + if (values.length === 0) return 0; + return sum(values) / values.length; +} + +/** + * μ΅œμ†Œκ°’ 계산 + */ +export function min(values: number[]): number { + if (values.length === 0) return 0; + return Math.min(...values.filter((v) => v !== null && v !== undefined)); +} + +/** + * μ΅œλŒ€κ°’ 계산 + */ +export function max(values: number[]): number { + if (values.length === 0) return 0; + return Math.max(...values.filter((v) => v !== null && v !== undefined)); +} + +/** + * κ³ μœ κ°’ 개수 계산 + */ +export function countDistinct(values: any[]): number { + return new Set(values.filter((v) => v !== null && v !== undefined)).size; +} + +/** + * 집계 νƒ€μž…μ— λ”°λ₯Έ 집계 μˆ˜ν–‰ + */ +export function aggregate( + values: any[], + type: AggregationType = "sum" +): number { + const numericValues = values + .map((v) => (typeof v === "number" ? v : parseFloat(v))) + .filter((v) => !isNaN(v)); + + switch (type) { + case "sum": + return sum(numericValues); + case "count": + return count(values); + case "avg": + return avg(numericValues); + case "min": + return min(numericValues); + case "max": + return max(numericValues); + case "countDistinct": + return countDistinct(values); + default: + return sum(numericValues); + } +} + +// ==================== 포맷 ν•¨μˆ˜ ==================== + +/** + * 숫자 ν¬λ§·νŒ… + */ +export function formatNumber( + value: number | null | undefined, + format?: PivotFieldFormat +): string { + if (value === null || value === undefined) return "-"; + + const { + type = "number", + precision = 0, + thousandSeparator = true, + prefix = "", + suffix = "", + } = format || {}; + + let formatted: string; + + switch (type) { + case "currency": + formatted = value.toLocaleString("ko-KR", { + minimumFractionDigits: precision, + maximumFractionDigits: precision, + }); + break; + + case "percent": + formatted = (value * 100).toLocaleString("ko-KR", { + minimumFractionDigits: precision, + maximumFractionDigits: precision, + }); + break; + + case "number": + default: + if (thousandSeparator) { + formatted = value.toLocaleString("ko-KR", { + minimumFractionDigits: precision, + maximumFractionDigits: precision, + }); + } else { + formatted = value.toFixed(precision); + } + break; + } + + return `${prefix}${formatted}${suffix}`; +} + +/** + * λ‚ μ§œ ν¬λ§·νŒ… + */ +export function formatDate( + value: Date | string | null | undefined, + format: string = "YYYY-MM-DD" +): string { + if (!value) return "-"; + + const date = typeof value === "string" ? new Date(value) : value; + + if (isNaN(date.getTime())) return "-"; + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const quarter = Math.ceil((date.getMonth() + 1) / 3); + + return format + .replace("YYYY", String(year)) + .replace("MM", month) + .replace("DD", day) + .replace("Q", `Q${quarter}`); +} + +/** + * 집계 νƒ€μž… 라벨 λ°˜ν™˜ + */ +export function getAggregationLabel(type: AggregationType): string { + const labels: Record = { + sum: "합계", + count: "개수", + avg: "평균", + min: "μ΅œμ†Œ", + max: "μ΅œλŒ€", + countDistinct: "κ³ μœ κ°’", + }; + return labels[type] || "합계"; +} + diff --git a/frontend/lib/registry/components/pivot-grid/utils/index.ts b/frontend/lib/registry/components/pivot-grid/utils/index.ts new file mode 100644 index 00000000..46d785c9 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/utils/index.ts @@ -0,0 +1,3 @@ +export * from "./aggregation"; +export * from "./pivotEngine"; + diff --git a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts new file mode 100644 index 00000000..5117c2df --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts @@ -0,0 +1,620 @@ +/** + * PivotGrid 데이터 처리 μ—”μ§„ + * μ›μ‹œ 데이터λ₯Ό ν”Όλ²— ꡬ쑰둜 λ³€ν™˜ν•©λ‹ˆλ‹€. + */ + +import { + PivotFieldConfig, + PivotResult, + PivotHeaderNode, + PivotFlatRow, + PivotFlatColumn, + PivotCellValue, + DateGroupInterval, + AggregationType, +} from "../types"; +import { aggregate, formatNumber, formatDate } from "./aggregation"; + +// ==================== 헬퍼 ν•¨μˆ˜ ==================== + +/** + * ν•„λ“œ κ°’ μΆ”μΆœ (λ‚ μ§œ κ·Έλ£Ήν•‘ 포함) + */ +function getFieldValue( + row: Record, + field: PivotFieldConfig +): string { + const rawValue = row[field.field]; + + if (rawValue === null || rawValue === undefined) { + return "(빈 κ°’)"; + } + + // λ‚ μ§œ κ·Έλ£Ήν•‘ 처리 + if (field.groupInterval && field.dataType === "date") { + const date = new Date(rawValue); + if (isNaN(date.getTime())) return String(rawValue); + + switch (field.groupInterval) { + case "year": + return String(date.getFullYear()); + case "quarter": + return `Q${Math.ceil((date.getMonth() + 1) / 3)}`; + case "month": + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; + case "week": + const weekNum = getWeekNumber(date); + return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`; + case "day": + return formatDate(date, "YYYY-MM-DD"); + default: + return String(rawValue); + } + } + + return String(rawValue); +} + +/** + * μ£Όμ°¨ 계산 + */ +function getWeekNumber(date: Date): number { + const d = new Date( + Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) + ); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); +} + +/** + * 경둜λ₯Ό ν‚€λ‘œ λ³€ν™˜ + */ +export function pathToKey(path: string[]): string { + return path.join("||"); +} + +/** + * ν‚€λ₯Ό 경둜둜 λ³€ν™˜ + */ +export function keyToPath(key: string): string[] { + return key.split("||"); +} + +// ==================== 헀더 생성 ==================== + +/** + * 계측적 헀더 λ…Έλ“œ 생성 + */ +function buildHeaderTree( + data: Record[], + fields: PivotFieldConfig[], + expandedPaths: Set +): PivotHeaderNode[] { + if (fields.length === 0) return []; + + // 첫 번째 ν•„λ“œλ‘œ κ·Έλ£Ήν™” + const firstField = fields[0]; + const groups = new Map[]>(); + + data.forEach((row) => { + const value = getFieldValue(row, firstField); + if (!groups.has(value)) { + groups.set(value, []); + } + groups.get(value)!.push(row); + }); + + // μ •λ ¬ + const sortedKeys = Array.from(groups.keys()).sort((a, b) => { + if (firstField.sortOrder === "desc") { + return b.localeCompare(a, "ko"); + } + return a.localeCompare(b, "ko"); + }); + + // λ…Έλ“œ 생성 + const nodes: PivotHeaderNode[] = []; + const remainingFields = fields.slice(1); + + for (const key of sortedKeys) { + const groupData = groups.get(key)!; + const path = [key]; + const pathKey = pathToKey(path); + + const node: PivotHeaderNode = { + value: key, + caption: key, + level: 0, + isExpanded: expandedPaths.has(pathKey), + path: path, + span: 1, + }; + + // μžμ‹ λ…Έλ“œ 생성 (ν™•μž₯된 경우만) + if (remainingFields.length > 0 && node.isExpanded) { + node.children = buildChildNodes( + groupData, + remainingFields, + path, + expandedPaths, + 1 + ); + // span 계산 + node.span = calculateSpan(node.children); + } + + nodes.push(node); + } + + return nodes; +} + +/** + * μžμ‹ λ…Έλ“œ μž¬κ·€ 생성 + */ +function buildChildNodes( + data: Record[], + fields: PivotFieldConfig[], + parentPath: string[], + expandedPaths: Set, + level: number +): PivotHeaderNode[] { + if (fields.length === 0) return []; + + const field = fields[0]; + const groups = new Map[]>(); + + data.forEach((row) => { + const value = getFieldValue(row, field); + if (!groups.has(value)) { + groups.set(value, []); + } + groups.get(value)!.push(row); + }); + + const sortedKeys = Array.from(groups.keys()).sort((a, b) => { + if (field.sortOrder === "desc") { + return b.localeCompare(a, "ko"); + } + return a.localeCompare(b, "ko"); + }); + + const nodes: PivotHeaderNode[] = []; + const remainingFields = fields.slice(1); + + for (const key of sortedKeys) { + const groupData = groups.get(key)!; + const path = [...parentPath, key]; + const pathKey = pathToKey(path); + + const node: PivotHeaderNode = { + value: key, + caption: key, + level: level, + isExpanded: expandedPaths.has(pathKey), + path: path, + span: 1, + }; + + if (remainingFields.length > 0 && node.isExpanded) { + node.children = buildChildNodes( + groupData, + remainingFields, + path, + expandedPaths, + level + 1 + ); + node.span = calculateSpan(node.children); + } + + nodes.push(node); + } + + return nodes; +} + +/** + * span 계산 (colspan/rowspan) + */ +function calculateSpan(children?: PivotHeaderNode[]): number { + if (!children || children.length === 0) return 1; + return children.reduce((sum, child) => sum + child.span, 0); +} + +// ==================== ν”Œλž« ꡬ쑰 λ³€ν™˜ ==================== + +/** + * 헀더 트리λ₯Ό ν”Œλž« ν–‰μœΌλ‘œ λ³€ν™˜ + */ +function flattenRows(nodes: PivotHeaderNode[]): PivotFlatRow[] { + const result: PivotFlatRow[] = []; + + function traverse(node: PivotHeaderNode) { + result.push({ + path: node.path, + level: node.level, + caption: node.caption, + isExpanded: node.isExpanded, + hasChildren: !!(node.children && node.children.length > 0), + }); + + if (node.isExpanded && node.children) { + for (const child of node.children) { + traverse(child); + } + } + } + + for (const node of nodes) { + traverse(node); + } + + return result; +} + +/** + * 헀더 트리λ₯Ό ν”Œλž« μ—΄λ‘œ λ³€ν™˜ (각 λ ˆλ²¨λ³„) + */ +function flattenColumns( + nodes: PivotHeaderNode[], + maxLevel: number +): PivotFlatColumn[][] { + const levels: PivotFlatColumn[][] = Array.from( + { length: maxLevel + 1 }, + () => [] + ); + + function traverse(node: PivotHeaderNode, currentLevel: number) { + levels[currentLevel].push({ + path: node.path, + level: currentLevel, + caption: node.caption, + span: node.span, + }); + + if (node.children && node.isExpanded) { + for (const child of node.children) { + traverse(child, currentLevel + 1); + } + } else if (currentLevel < maxLevel) { + // ν™•μž₯λ˜μ§€ μ•Šμ€ λ…Έλ“œλŠ” λ‹€μŒ λ ˆλ²¨λ“€μ—μ„œ span으둜 처리 + for (let i = currentLevel + 1; i <= maxLevel; i++) { + levels[i].push({ + path: node.path, + level: i, + caption: "", + span: node.span, + }); + } + } + } + + for (const node of nodes) { + traverse(node, 0); + } + + return levels; +} + +/** + * μ—΄ ν—€λ”μ˜ μ΅œλŒ€ 깊이 계산 + */ +function getMaxColumnLevel( + nodes: PivotHeaderNode[], + totalFields: number +): number { + let maxLevel = 0; + + function traverse(node: PivotHeaderNode, level: number) { + maxLevel = Math.max(maxLevel, level); + if (node.children && node.isExpanded) { + for (const child of node.children) { + traverse(child, level + 1); + } + } + } + + for (const node of nodes) { + traverse(node, 0); + } + + return Math.min(maxLevel, totalFields - 1); +} + +// ==================== 데이터 맀트릭슀 생성 ==================== + +/** + * 데이터 맀트릭슀 생성 + */ +function buildDataMatrix( + data: Record[], + rowFields: PivotFieldConfig[], + columnFields: PivotFieldConfig[], + dataFields: PivotFieldConfig[], + flatRows: PivotFlatRow[], + flatColumnLeaves: string[][] +): Map { + const matrix = new Map(); + + // 각 셀에 λŒ€ν•΄ ν•΄λ‹Ήν•˜λŠ” 데이터 집계 + for (const row of flatRows) { + for (const colPath of flatColumnLeaves) { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`; + + // ν•΄λ‹Ή ν–‰/μ—΄ κ²½λ‘œμ— λ§žλŠ” 데이터 필터링 + const filteredData = data.filter((record) => { + // ν–‰ 쑰건 확인 + for (let i = 0; i < row.path.length; i++) { + const field = rowFields[i]; + if (!field) continue; + const value = getFieldValue(record, field); + if (value !== row.path[i]) return false; + } + + // μ—΄ 쑰건 확인 + for (let i = 0; i < colPath.length; i++) { + const field = columnFields[i]; + if (!field) continue; + const value = getFieldValue(record, field); + if (value !== colPath[i]) return false; + } + + return true; + }); + + // 데이터 ν•„λ“œλ³„ 집계 + const cellValues: PivotCellValue[] = dataFields.map((dataField) => { + const values = filteredData.map((r) => r[dataField.field]); + const aggregatedValue = aggregate( + values, + dataField.summaryType || "sum" + ); + const formattedValue = formatNumber( + aggregatedValue, + dataField.format + ); + + return { + field: dataField.field, + value: aggregatedValue, + formattedValue, + }; + }); + + matrix.set(cellKey, cellValues); + } + } + + return matrix; +} + +/** + * μ—΄ leaf λ…Έλ“œ 경둜 μΆ”μΆœ + */ +function getColumnLeaves(nodes: PivotHeaderNode[]): string[][] { + const leaves: string[][] = []; + + function traverse(node: PivotHeaderNode) { + if (!node.isExpanded || !node.children || node.children.length === 0) { + leaves.push(node.path); + } else { + for (const child of node.children) { + traverse(child); + } + } + } + + for (const node of nodes) { + traverse(node); + } + + // μ—΄ ν•„λ“œκ°€ 없을 경우 빈 경둜 μΆ”κ°€ + if (leaves.length === 0) { + leaves.push([]); + } + + return leaves; +} + +// ==================== 총합계 계산 ==================== + +/** + * 총합계 계산 + */ +function calculateGrandTotals( + data: Record[], + rowFields: PivotFieldConfig[], + columnFields: PivotFieldConfig[], + dataFields: PivotFieldConfig[], + flatRows: PivotFlatRow[], + flatColumnLeaves: string[][] +): { + row: Map; + column: Map; + grand: PivotCellValue[]; +} { + const rowTotals = new Map(); + const columnTotals = new Map(); + + // 행별 총합 (각 ν–‰μ˜ λͺ¨λ“  μ—΄ 합계) + for (const row of flatRows) { + const filteredData = data.filter((record) => { + for (let i = 0; i < row.path.length; i++) { + const field = rowFields[i]; + if (!field) continue; + const value = getFieldValue(record, field); + if (value !== row.path[i]) return false; + } + return true; + }); + + const cellValues: PivotCellValue[] = dataFields.map((dataField) => { + const values = filteredData.map((r) => r[dataField.field]); + const aggregatedValue = aggregate(values, dataField.summaryType || "sum"); + return { + field: dataField.field, + value: aggregatedValue, + formattedValue: formatNumber(aggregatedValue, dataField.format), + }; + }); + + rowTotals.set(pathToKey(row.path), cellValues); + } + + // 열별 총합 (각 μ—΄μ˜ λͺ¨λ“  ν–‰ 합계) + for (const colPath of flatColumnLeaves) { + const filteredData = data.filter((record) => { + for (let i = 0; i < colPath.length; i++) { + const field = columnFields[i]; + if (!field) continue; + const value = getFieldValue(record, field); + if (value !== colPath[i]) return false; + } + return true; + }); + + const cellValues: PivotCellValue[] = dataFields.map((dataField) => { + const values = filteredData.map((r) => r[dataField.field]); + const aggregatedValue = aggregate(values, dataField.summaryType || "sum"); + return { + field: dataField.field, + value: aggregatedValue, + formattedValue: formatNumber(aggregatedValue, dataField.format), + }; + }); + + columnTotals.set(pathToKey(colPath), cellValues); + } + + // λŒ€μ΄ν•© + const grandValues: PivotCellValue[] = dataFields.map((dataField) => { + const values = data.map((r) => r[dataField.field]); + const aggregatedValue = aggregate(values, dataField.summaryType || "sum"); + return { + field: dataField.field, + value: aggregatedValue, + formattedValue: formatNumber(aggregatedValue, dataField.format), + }; + }); + + return { + row: rowTotals, + column: columnTotals, + grand: grandValues, + }; +} + +// ==================== 메인 ν•¨μˆ˜ ==================== + +/** + * ν”Όλ²— 데이터 처리 + */ +export function processPivotData( + data: Record[], + fields: PivotFieldConfig[], + expandedRowPaths: string[][] = [], + expandedColumnPaths: string[][] = [] +): PivotResult { + // μ˜μ—­λ³„ ν•„λ“œ 뢄리 + const rowFields = fields + .filter((f) => f.area === "row" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + + const columnFields = fields + .filter((f) => f.area === "column" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + + const dataFields = fields + .filter((f) => f.area === "data" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + + const filterFields = fields.filter( + (f) => f.area === "filter" && f.visible !== false + ); + + // ν•„ν„° 적용 + let filteredData = data; + for (const filterField of filterFields) { + if (filterField.filterValues && filterField.filterValues.length > 0) { + filteredData = filteredData.filter((row) => { + const value = getFieldValue(row, filterField); + if (filterField.filterType === "exclude") { + return !filterField.filterValues!.includes(value); + } + return filterField.filterValues!.includes(value); + }); + } + } + + // ν™•μž₯ 경둜 Set λ³€ν™˜ + const expandedRowSet = new Set(expandedRowPaths.map(pathToKey)); + const expandedColSet = new Set(expandedColumnPaths.map(pathToKey)); + + // κΈ°λ³Έ ν™•μž₯: 첫 번째 레벨 λͺ¨λ‘ ν™•μž₯ + if (expandedRowPaths.length === 0 && rowFields.length > 0) { + const firstField = rowFields[0]; + const uniqueValues = new Set( + filteredData.map((row) => getFieldValue(row, firstField)) + ); + uniqueValues.forEach((val) => expandedRowSet.add(val)); + } + + if (expandedColumnPaths.length === 0 && columnFields.length > 0) { + const firstField = columnFields[0]; + const uniqueValues = new Set( + filteredData.map((row) => getFieldValue(row, firstField)) + ); + uniqueValues.forEach((val) => expandedColSet.add(val)); + } + + // 헀더 트리 생성 + const rowHeaders = buildHeaderTree(filteredData, rowFields, expandedRowSet); + const columnHeaders = buildHeaderTree( + filteredData, + columnFields, + expandedColSet + ); + + // ν”Œλž« ꡬ쑰 λ³€ν™˜ + const flatRows = flattenRows(rowHeaders); + const flatColumnLeaves = getColumnLeaves(columnHeaders); + const maxColumnLevel = getMaxColumnLevel(columnHeaders, columnFields.length); + const flatColumns = flattenColumns(columnHeaders, maxColumnLevel); + + // 데이터 맀트릭슀 생성 + const dataMatrix = buildDataMatrix( + filteredData, + rowFields, + columnFields, + dataFields, + flatRows, + flatColumnLeaves + ); + + // 총합계 계산 + const grandTotals = calculateGrandTotals( + filteredData, + rowFields, + columnFields, + dataFields, + flatRows, + flatColumnLeaves + ); + + return { + rowHeaders, + columnHeaders, + dataMatrix, + flatRows, + flatColumns: flatColumnLeaves.map((path, idx) => ({ + path, + level: path.length - 1, + caption: path[path.length - 1] || "", + span: 1, + })), + grandTotals, + }; +} +