diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index f826a86a..7e1108c3 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -42,6 +42,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/bwip-js": "^3.2.3", "@types/compression": "^1.7.5", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", @@ -3214,6 +3215,16 @@ "@types/node": "*" } }, + "node_modules/@types/bwip-js": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@types/bwip-js/-/bwip-js-3.2.3.tgz", + "integrity": "sha512-kgL1GOW7n5FhlC5aXnckaEim0rz1cFM4t9/xUwuNXCIDnWLx8ruQ4JQkG6znq4GQFovNLhQy5JdgbDwJw4D/zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/compression": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index e9ce3729..b1bfa319 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -56,6 +56,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/bwip-js": "^3.2.3", "@types/compression": "^1.7.5", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 9ca202ed..e28e1755 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -88,9 +88,6 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // ๋‚ด๋ถ€ ์ธ // ๐Ÿ†• ์—ฐ๊ด€ ๋ฐ์ดํ„ฐ ๋ฒ„ํŠผ ์ปดํฌ๋„ŒํŠธ import "./related-data-buttons/RelatedDataButtonsRenderer"; // ์ขŒ์ธก ์„ ํƒ ๋ฐ์ดํ„ฐ ๊ธฐ๋ฐ˜ ์—ฐ๊ด€ ํ…Œ์ด๋ธ” ๋ฒ„ํŠผ ํ‘œ์‹œ -// ๐Ÿ†• ํ”ผ๋ฒ— ๊ทธ๋ฆฌ๋“œ ์ปดํฌ๋„ŒํŠธ -import "./pivot-grid/PivotGridRenderer"; // ๋‹ค์ฐจ์› ๋ฐ์ดํ„ฐ ๋ถ„์„ ํ”ผ๋ฒ— ํ…Œ์ด๋ธ” - /** * ์ปดํฌ๋„ŒํŠธ ์ดˆ๊ธฐํ™” ํ•จ์ˆ˜ */ diff --git a/frontend/lib/registry/components/pivot-grid/PLAN.md b/frontend/lib/registry/components/pivot-grid/PLAN.md new file mode 100644 index 00000000..7b96ab38 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/PLAN.md @@ -0,0 +1,159 @@ +# PivotGrid ์ปดํฌ๋„ŒํŠธ ์ „์ฒด ๊ตฌํ˜„ ๊ณ„ํš + +## ๊ฐœ์š” +DevExtreme PivotGrid (https://js.devexpress.com/React/Demos/WidgetsGallery/Demo/PivotGrid/Overview/FluentBlueLight/) ์ˆ˜์ค€์˜ ๋‹ค์ฐจ์› ๋ฐ์ดํ„ฐ ๋ถ„์„ ์ปดํฌ๋„ŒํŠธ ๊ตฌํ˜„ + +## ํ˜„์žฌ ์ƒํƒœ: โœ… ๋ชจ๋“  ๊ธฐ๋Šฅ ๊ตฌํ˜„ ์™„๋ฃŒ! + +--- + +## ๊ตฌํ˜„๋œ ๊ธฐ๋Šฅ ๋ชฉ๋ก + +### 1. ๊ธฐ๋ณธ ํ”ผ๋ฒ— ํ…Œ์ด๋ธ” โœ… +- [x] ํ”ผ๋ฒ— ํ…Œ์ด๋ธ” ๋ Œ๋”๋ง +- [x] ํ–‰/์—ด ํ™•์žฅ/์ถ•์†Œ +- [x] ํ•ฉ๊ณ„/์†Œ๊ณ„ ํ‘œ์‹œ +- [x] ์ „์ฒด ํ™•์žฅ/์ถ•์†Œ ๋ฒ„ํŠผ + +### 2. ํ•„๋“œ ํŒจ๋„ (๋“œ๋ž˜๊ทธ์•ค๋“œ๋กญ) โœ… +- [x] ์ƒ๋‹จ์— 4๊ฐœ ์˜์—ญ ํ‘œ์‹œ (ํ•„ํ„ฐ, ์—ด, ํ–‰, ๋ฐ์ดํ„ฐ) +- [x] ๊ฐ ์˜์—ญ์— ๋ฐฐ์น˜๋œ ํ•„๋“œ ์นฉ/ํƒœ๊ทธ ํ‘œ์‹œ +- [x] ํ•„๋“œ ์ œ๊ฑฐ ๋ฒ„ํŠผ (X) +- [x] ํ•„๋“œ ๊ฐ„ ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ ์ง€์› (@dnd-kit ์‚ฌ์šฉ) +- [x] ์˜์—ญ ๊ฐ„ ํ•„๋“œ ์ด๋™ +- [x] ๊ฐ™์€ ์˜์—ญ ๋‚ด ์ˆœ์„œ ๋ณ€๊ฒฝ +- [x] ๋“œ๋ž˜๊ทธ ์‹œ ์‹œ๊ฐ์  ํ”ผ๋“œ๋ฐฑ + +### 3. ํ•„๋“œ ์„ ํƒ๊ธฐ (๋ชจ๋‹ฌ) โœ… +- [x] ๋ชจ๋‹ฌ ์—ด๊ธฐ/๋‹ซ๊ธฐ +- [x] ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ํ•„๋“œ ๋ชฉ๋ก +- [x] ํ•„๋“œ ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ +- [x] ํ•„๋“œ๋ณ„ ์˜์—ญ ์„ ํƒ ๋“œ๋กญ๋‹ค์šด +- [x] ๋ฐ์ดํ„ฐ ํƒ€์ž… ์•„์ด์ฝ˜ ํ‘œ์‹œ +- [x] ์ง‘๊ณ„ ํ•จ์ˆ˜ ์„ ํƒ (๋ฐ์ดํ„ฐ ์˜์—ญ) +- [x] ํ‘œ์‹œ ๋ชจ๋“œ ์„ ํƒ (๋ฐ์ดํ„ฐ ์˜์—ญ) + +### 4. ๋ฐ์ดํ„ฐ ์š”์•ฝ (๋ˆ„๊ณ„, % ๋ชจ๋“œ) โœ… +- [x] ์ ˆ๋Œ€๊ฐ’ ํ‘œ์‹œ (๊ธฐ๋ณธ) +- [x] ํ–‰ ์ด๊ณ„ ๋Œ€๋น„ % +- [x] ์—ด ์ด๊ณ„ ๋Œ€๋น„ % +- [x] ์ „์ฒด ์ด๊ณ„ ๋Œ€๋น„ % +- [x] ํ–‰/์—ด ๋ฐฉํ–ฅ ๋ˆ„๊ณ„ +- [x] ์ด์ „ ๋Œ€๋น„ ์ฐจ์ด +- [x] ์ด์ „ ๋Œ€๋น„ % ์ฐจ์ด + +### 5. ํ•„ํ„ฐ๋ง โœ… +- [x] ํ•„ํ„ฐ ํŒ์—… ์ปดํฌ๋„ŒํŠธ (FilterPopup) +- [x] ๊ฐ’ ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ +- [x] ์ฒดํฌ๋ฐ•์Šค ๊ธฐ๋ฐ˜ ๊ฐ’ ์„ ํƒ +- [x] ํฌํ•จ/์ œ์™ธ ๋ชจ๋“œ +- [x] ์ „์ฒด ์„ ํƒ/ํ•ด์ œ +- [x] ์„ ํƒ๋œ ํ•ญ๋ชฉ ์ˆ˜ ํ‘œ์‹œ + +### 6. Drill Down โœ… +- [x] ์…€ ๋”๋ธ”ํด๋ฆญ ์‹œ ์ƒ์„ธ ๋ฐ์ดํ„ฐ ๋ชจ๋‹ฌ +- [x] ์›๋ณธ ๋ฐ์ดํ„ฐ ํ…Œ์ด๋ธ” ํ‘œ์‹œ +- [x] ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ +- [x] ์ •๋ ฌ ๊ธฐ๋Šฅ +- [x] ํŽ˜์ด์ง€๋„ค์ด์…˜ +- [x] CSV/Excel ๋‚ด๋ณด๋‚ด๊ธฐ + +### 7. Virtual Scrolling โœ… +- [x] useVirtualScroll ํ›… (ํ–‰) +- [x] useVirtualColumnScroll ํ›… (์—ด) +- [x] useVirtual2DScroll ํ›… (ํ–‰+์—ด) +- [x] overscan ๋ฒ„ํผ ์ง€์› + +### 8. Excel ๋‚ด๋ณด๋‚ด๊ธฐ โœ… +- [x] xlsx ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์šฉ +- [x] ํ”ผ๋ฒ— ๋ฐ์ดํ„ฐ Excel ๋‚ด๋ณด๋‚ด๊ธฐ +- [x] Drill Down ๋ฐ์ดํ„ฐ Excel ๋‚ด๋ณด๋‚ด๊ธฐ +- [x] CSV ๋‚ด๋ณด๋‚ด๊ธฐ (๊ธฐ๋ณธ) +- [x] ์Šคํƒ€์ผ๋ง (ํ—ค๋”, ๋ฐ์ดํ„ฐ, ์ด๊ณ„) +- [x] ์ˆซ์ž ํฌ๋งท + +### 9. ์ฐจํŠธ ํ†ตํ•ฉ โœ… +- [x] recharts ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์šฉ +- [x] ๋ง‰๋Œ€ ์ฐจํŠธ +- [x] ๋ˆ„์  ๋ง‰๋Œ€ ์ฐจํŠธ +- [x] ์„  ์ฐจํŠธ +- [x] ์˜์—ญ ์ฐจํŠธ +- [x] ํŒŒ์ด ์ฐจํŠธ +- [x] ๋ฒ”๋ก€ ํ‘œ์‹œ +- [x] ์ปค์Šคํ…€ ํˆดํŒ +- [x] ์ฐจํŠธ ํ† ๊ธ€ ๋ฒ„ํŠผ + +### 10. ์กฐ๊ฑด๋ถ€ ์„œ์‹ (Conditional Formatting) โœ… +- [x] Color Scale (์ƒ‰์ƒ ๊ทธ๋ผ๋ฐ์ด์…˜) +- [x] Data Bar (๋ฐ์ดํ„ฐ ๋ง‰๋Œ€) +- [x] Icon Set (์•„์ด์ฝ˜) +- [x] Cell Value (์กฐ๊ฑด ๊ธฐ๋ฐ˜ ์Šคํƒ€์ผ) +- [x] ConfigPanel์—์„œ ์„ค์ • UI + +### 11. ์ƒํƒœ ์ €์žฅ/๋ณต์› โœ… +- [x] usePivotState ํ›… +- [x] localStorage/sessionStorage ์ง€์› +- [x] ์ž๋™ ์ €์žฅ (๋””๋ฐ”์šด์Šค) + +### 12. ConfigPanel ๊ณ ๋„ํ™” โœ… +- [x] ๋ฐ์ดํ„ฐ ์†Œ์Šค ์„ค์ • (ํ…Œ์ด๋ธ” ์„ ํƒ) +- [x] ํ•„๋“œ๋ณ„ ์˜์—ญ ์„ค์ • (ํ–‰, ์—ด, ๋ฐ์ดํ„ฐ, ํ•„ํ„ฐ) +- [x] ์ด๊ณ„ ์˜ต์…˜ ์„ค์ • +- [x] ์Šคํƒ€์ผ ์„ค์ • (ํ…Œ๋งˆ, ๊ต์ฐจ ์ƒ‰์ƒ ๋“ฑ) +- [x] ๋‚ด๋ณด๋‚ด๊ธฐ ์„ค์ • (Excel/CSV) +- [x] ์ฐจํŠธ ์„ค์ • UI +- [x] ํ•„๋“œ ์„ ํƒ๊ธฐ ์„ค์ • UI +- [x] ์กฐ๊ฑด๋ถ€ ์„œ์‹ ์„ค์ • UI +- [x] ํฌ๊ธฐ ์„ค์ • + +--- + +## ํŒŒ์ผ ๊ตฌ์กฐ + +``` +pivot-grid/ +โ”œโ”€โ”€ components/ +โ”‚ โ”œโ”€โ”€ FieldPanel.tsx # ํ•„๋“œ ํŒจ๋„ (๋“œ๋ž˜๊ทธ์•ค๋“œ๋กญ) +โ”‚ โ”œโ”€โ”€ FieldChooser.tsx # ํ•„๋“œ ์„ ํƒ๊ธฐ ๋ชจ๋‹ฌ +โ”‚ โ”œโ”€โ”€ DrillDownModal.tsx # Drill Down ๋ชจ๋‹ฌ +โ”‚ โ”œโ”€โ”€ FilterPopup.tsx # ํ•„ํ„ฐ ํŒ์—… +โ”‚ โ”œโ”€โ”€ PivotChart.tsx # ์ฐจํŠธ ์ปดํฌ๋„ŒํŠธ +โ”‚ โ””โ”€โ”€ index.ts # ๋‚ด๋ณด๋‚ด๊ธฐ +โ”œโ”€โ”€ hooks/ +โ”‚ โ”œโ”€โ”€ useVirtualScroll.ts # ๊ฐ€์ƒ ์Šคํฌ๋กค ํ›… +โ”‚ โ”œโ”€โ”€ usePivotState.ts # ์ƒํƒœ ์ €์žฅ ํ›… +โ”‚ โ””โ”€โ”€ index.ts # ๋‚ด๋ณด๋‚ด๊ธฐ +โ”œโ”€โ”€ utils/ +โ”‚ โ”œโ”€โ”€ aggregation.ts # ์ง‘๊ณ„ ํ•จ์ˆ˜ +โ”‚ โ”œโ”€โ”€ pivotEngine.ts # ํ”ผ๋ฒ— ์—”์ง„ +โ”‚ โ”œโ”€โ”€ exportExcel.ts # Excel ๋‚ด๋ณด๋‚ด๊ธฐ +โ”‚ โ”œโ”€โ”€ conditionalFormat.ts # ์กฐ๊ฑด๋ถ€ ์„œ์‹ +โ”‚ โ””โ”€โ”€ index.ts # ๋‚ด๋ณด๋‚ด๊ธฐ +โ”œโ”€โ”€ types.ts # ํƒ€์ž… ์ •์˜ +โ”œโ”€โ”€ PivotGridComponent.tsx # ๋ฉ”์ธ ์ปดํฌ๋„ŒํŠธ +โ”œโ”€โ”€ PivotGridConfigPanel.tsx # ์„ค์ • ํŒจ๋„ +โ”œโ”€โ”€ PivotGridRenderer.tsx # ๋ Œ๋”๋Ÿฌ +โ”œโ”€โ”€ index.ts # ๋ชจ๋“ˆ ๋‚ด๋ณด๋‚ด๊ธฐ +โ””โ”€โ”€ PLAN.md # ์ด ํŒŒ์ผ +``` + +--- + +## ํ›„์ˆœ์œ„ ๊ธฐ๋Šฅ (์„ ํƒ์ ) + +๋‹ค์Œ ๊ธฐ๋Šฅ๋“ค์€ ํ•„์š” ์‹œ ์ถ”๊ฐ€ ๊ตฌํ˜„ ๊ฐ€๋Šฅ: + +### ๋ฐ์ดํ„ฐ ๋ฐ”์ธ๋”ฉ ํ™•์žฅ +- [ ] OLAP Data Source ์—ฐ๋™ (๋ณต์žก) +- [ ] GraphQL ์—ฐ๋™ +- [ ] ์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ ์—…๋ฐ์ดํŠธ (WebSocket) + +### ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ +- [ ] ํ”ผ๋ฒ— ํ…Œ์ด๋ธ” ๋ณ‘ํ•ฉ (์—ฌ๋Ÿฌ ๋ฐ์ดํ„ฐ ์†Œ์Šค) +- [ ] ๊ณ„์‚ฐ ํ•„๋“œ (์ปค์Šคํ…€ ์ˆ˜์‹) +- [ ] ๋ฐ์ดํ„ฐ ์ •๋ ฌ ์˜ต์…˜ ๊ฐ•ํ™” +- [ ] ๊ทธ๋ฃนํ•‘ ์˜ต์…˜ (๋‚ ์งœ ๊ทธ๋ฃนํ•‘ ๋“ฑ) + +--- + +## ์™„๋ฃŒ์ผ: 2026-01-08 diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index b81057a3..e7904a95 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -5,7 +5,7 @@ * ๋‹ค์ฐจ์› ๋ฐ์ดํ„ฐ ๋ถ„์„์„ ์œ„ํ•œ ํ”ผ๋ฒ— ํ…Œ์ด๋ธ” */ -import React, { useState, useMemo, useCallback } from "react"; +import React, { useState, useMemo, useCallback, useEffect } from "react"; import { cn } from "@/lib/utils"; import { PivotGridProps, @@ -15,8 +15,15 @@ import { PivotFlatRow, PivotCellValue, PivotGridState, + PivotAreaType, } from "./types"; import { processPivotData, pathToKey } from "./utils/pivotEngine"; +import { exportPivotToExcel } from "./utils/exportExcel"; +import { getConditionalStyle, formatStyleToReact, CellFormatStyle } from "./utils/conditionalFormat"; +import { FieldPanel } from "./components/FieldPanel"; +import { FieldChooser } from "./components/FieldChooser"; +import { DrillDownModal } from "./components/DrillDownModal"; +import { PivotChart } from "./components/PivotChart"; import { ChevronRight, ChevronDown, @@ -25,6 +32,9 @@ import { RefreshCw, Maximize2, Minimize2, + LayoutGrid, + FileSpreadsheet, + BarChart3, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -79,13 +89,22 @@ interface DataCellProps { values: PivotCellValue[]; isTotal?: boolean; onClick?: () => void; + onDoubleClick?: () => void; + conditionalStyle?: CellFormatStyle; } const DataCell: React.FC = ({ values, isTotal = false, onClick, + onDoubleClick, + conditionalStyle, }) => { + // ์กฐ๊ฑด๋ถ€ ์„œ์‹ ์Šคํƒ€์ผ ๊ณ„์‚ฐ + const cellStyle = conditionalStyle ? formatStyleToReact(conditionalStyle) : {}; + const hasDataBar = conditionalStyle?.dataBarWidth !== undefined; + const icon = conditionalStyle?.icon; + if (!values || values.length === 0) { return ( = ({ "px-2 py-1.5 text-right text-sm", isTotal && "bg-primary/5 font-medium" )} + style={cellStyle} + onClick={onClick} + onDoubleClick={onDoubleClick} > - @@ -105,14 +127,29 @@ const DataCell: React.FC = ({ return ( - {values[0].formattedValue} + {/* Data Bar */} + {hasDataBar && ( +
+ )} + + {icon && {icon}} + {values[0].formattedValue} + ); } @@ -124,14 +161,28 @@ const DataCell: React.FC = ({ - {val.formattedValue} + {hasDataBar && ( +
+ )} + + {icon && {icon}} + {val.formattedValue} + ))} @@ -142,7 +193,7 @@ const DataCell: React.FC = ({ export const PivotGridComponent: React.FC = ({ title, - fields = [], + fields: initialFields = [], totals = { showRowGrandTotals: true, showColumnGrandTotals: true, @@ -157,24 +208,49 @@ export const PivotGridComponent: React.FC = ({ alternateRowColors: true, highlightTotals: true, }, + fieldChooser, + chart: chartConfig, allowExpandAll = true, height = "auto", maxHeight, exportConfig, data: externalData, onCellClick, + onCellDoubleClick, + onFieldDrop, onExpandChange, }) => { + // ๋””๋ฒ„๊น… ๋กœ๊ทธ + console.log("๐Ÿ”ถ PivotGridComponent props:", { + title, + hasExternalData: !!externalData, + externalDataLength: externalData?.length, + initialFieldsLength: initialFields?.length, + }); // ==================== ์ƒํƒœ ==================== + const [fields, setFields] = useState(initialFields); const [pivotState, setPivotState] = useState({ expandedRowPaths: [], expandedColumnPaths: [], sortConfig: null, filterConfig: {}, }); - const [isFullscreen, setIsFullscreen] = useState(false); + const [showFieldPanel, setShowFieldPanel] = useState(true); + const [showFieldChooser, setShowFieldChooser] = useState(false); + const [drillDownData, setDrillDownData] = useState<{ + open: boolean; + cellData: PivotCellData | null; + }>({ open: false, cellData: null }); + const [showChart, setShowChart] = useState(chartConfig?.enabled || false); + + // ์™ธ๋ถ€ fields ๋ณ€๊ฒฝ ์‹œ ๋™๊ธฐํ™” + useEffect(() => { + if (initialFields.length > 0) { + setFields(initialFields); + } + }, [initialFields]); // ๋ฐ์ดํ„ฐ const data = externalData || []; @@ -205,6 +281,43 @@ export const PivotGridComponent: React.FC = ({ [fields] ); + const filterFields = useMemo( + () => + fields + .filter((f) => f.area === "filter" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), + [fields] + ); + + // ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ํ•„๋“œ ๋ชฉ๋ก (FieldChooser์šฉ) + const availableFields = useMemo(() => { + if (data.length === 0) return []; + + const sampleRow = data[0]; + return Object.keys(sampleRow).map((key) => { + const existingField = fields.find((f) => f.field === key); + const value = sampleRow[key]; + + // ๋ฐ์ดํ„ฐ ํƒ€์ž… ์ถ”๋ก  + let dataType: "string" | "number" | "date" | "boolean" = "string"; + if (typeof value === "number") dataType = "number"; + else if (typeof value === "boolean") dataType = "boolean"; + else if (value instanceof Date) dataType = "date"; + else if (typeof value === "string") { + // ๋‚ ์งœ ๋ฌธ์ž์—ด ๊ฐ์ง€ + if (/^\d{4}-\d{2}-\d{2}/.test(value)) dataType = "date"; + } + + return { + field: key, + caption: existingField?.caption || key, + dataType, + isSelected: existingField?.visible !== false, + currentArea: existingField?.area, + }; + }); + }, [data, fields]); + // ==================== ํ”ผ๋ฒ— ์ฒ˜๋ฆฌ ==================== const pivotResult = useMemo(() => { @@ -212,16 +325,83 @@ export const PivotGridComponent: React.FC = ({ return null; } + const visibleFields = fields.filter((f) => f.visible !== false); + if (visibleFields.filter((f) => f.area !== "filter").length === 0) { + return null; + } + return processPivotData( data, - fields, + visibleFields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths ); }, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); + // ์กฐ๊ฑด๋ถ€ ์„œ์‹์šฉ ์ „์ฒด ๊ฐ’ ์ˆ˜์ง‘ + const allCellValues = useMemo(() => { + if (!pivotResult) return new Map(); + + const valuesByField = new Map(); + + // ๋ฐ์ดํ„ฐ ๋งคํŠธ๋ฆญ์Šค์—์„œ ๋ชจ๋“  ๊ฐ’ ์ˆ˜์ง‘ + pivotResult.dataMatrix.forEach((values) => { + values.forEach((val) => { + if (val.field && typeof val.value === "number" && !isNaN(val.value)) { + const existing = valuesByField.get(val.field) || []; + existing.push(val.value); + valuesByField.set(val.field, existing); + } + }); + }); + + // ํ–‰ ์ด๊ณ„ ๊ฐ’ ์ˆ˜์ง‘ + pivotResult.grandTotals.row.forEach((values) => { + values.forEach((val) => { + if (val.field && typeof val.value === "number" && !isNaN(val.value)) { + const existing = valuesByField.get(val.field) || []; + existing.push(val.value); + valuesByField.set(val.field, existing); + } + }); + }); + + // ์—ด ์ด๊ณ„ ๊ฐ’ ์ˆ˜์ง‘ + pivotResult.grandTotals.column.forEach((values) => { + values.forEach((val) => { + if (val.field && typeof val.value === "number" && !isNaN(val.value)) { + const existing = valuesByField.get(val.field) || []; + existing.push(val.value); + valuesByField.set(val.field, existing); + } + }); + }); + + return valuesByField; + }, [pivotResult]); + + // ์กฐ๊ฑด๋ถ€ ์„œ์‹ ์Šคํƒ€์ผ ๊ณ„์‚ฐ ํ—ฌํผ + const getCellConditionalStyle = useCallback( + (value: number | undefined, field: string): CellFormatStyle => { + if (!style?.conditionalFormats || style.conditionalFormats.length === 0) { + return {}; + } + const allValues = allCellValues.get(field) || []; + return getConditionalStyle(value, field, style.conditionalFormats, allValues); + }, + [style?.conditionalFormats, allCellValues] + ); + // ==================== ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ ==================== + // ํ•„๋“œ ๋ณ€๊ฒฝ + const handleFieldsChange = useCallback( + (newFields: PivotFieldConfig[]) => { + setFields(newFields); + }, + [] + ); + // ํ–‰ ํ™•์žฅ/์ถ•์†Œ const handleToggleRowExpand = useCallback( (path: string[]) => { @@ -256,7 +436,6 @@ export const PivotGridComponent: React.FC = ({ if (!pivotResult) return; const allRowPaths: string[][] = []; - pivotResult.flatRows.forEach((row) => { if (row.hasChildren) { allRowPaths.push(row.path); @@ -296,6 +475,27 @@ export const PivotGridComponent: React.FC = ({ [onCellClick] ); + // ์…€ ๋”๋ธ”ํด๋ฆญ (Drill Down) + const handleCellDoubleClick = useCallback( + (rowPath: string[], colPath: string[], values: PivotCellValue[]) => { + const cellData: PivotCellData = { + value: values[0]?.value, + rowPath, + columnPath: colPath, + field: values[0]?.field, + }; + + // Drill Down ๋ชจ๋‹ฌ ์—ด๊ธฐ + setDrillDownData({ open: true, cellData }); + + // ์™ธ๋ถ€ ์ฝœ๋ฐฑ ํ˜ธ์ถœ + if (onCellDoubleClick) { + onCellDoubleClick(cellData); + } + }, + [onCellDoubleClick] + ); + // CSV ๋‚ด๋ณด๋‚ด๊ธฐ const handleExportCSV = useCallback(() => { if (!pivotResult) return; @@ -354,6 +554,20 @@ export const PivotGridComponent: React.FC = ({ link.click(); }, [pivotResult, totals, title]); + // Excel ๋‚ด๋ณด๋‚ด๊ธฐ + const handleExportExcel = useCallback(async () => { + if (!pivotResult) return; + + try { + await exportPivotToExcel(pivotResult, fields, totals, { + fileName: title || "pivot_export", + title: title, + }); + } catch (error) { + console.error("Excel ๋‚ด๋ณด๋‚ด๊ธฐ ์‹คํŒจ:", error); + } + }, [pivotResult, fields, totals, title]); + // ==================== ๋ Œ๋”๋ง ==================== // ๋นˆ ์ƒํƒœ @@ -374,20 +588,51 @@ export const PivotGridComponent: React.FC = ({ } // ํ•„๋“œ ๋ฏธ์„ค์ • - if (fields.length === 0) { + const hasActiveFields = fields.some( + (f) => f.visible !== false && f.area !== "filter" + ); + if (!hasActiveFields) { return (
- -

ํ•„๋“œ๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค

-

- ํ–‰, ์—ด, ๋ฐ์ดํ„ฐ ์˜์—ญ์— ํ•„๋“œ๋ฅผ ๋ฐฐ์น˜ํ•ด์ฃผ์„ธ์š” -

+ {/* ํ•„๋“œ ํŒจ๋„ */} + setShowFieldPanel(!showFieldPanel)} + /> + + {/* ์•ˆ๋‚ด ๋ฉ”์‹œ์ง€ */} +
+ +

ํ•„๋“œ๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค

+

+ ํ–‰, ์—ด, ๋ฐ์ดํ„ฐ ์˜์—ญ์— ํ•„๋“œ๋ฅผ ๋ฐฐ์น˜ํ•ด์ฃผ์„ธ์š” +

+ +
+ + {/* ํ•„๋“œ ์„ ํƒ๊ธฐ ๋ชจ๋‹ฌ */} +
); } @@ -416,6 +661,14 @@ export const PivotGridComponent: React.FC = ({ maxHeight: isFullscreen ? "none" : maxHeight, }} > + {/* ํ•„๋“œ ํŒจ๋„ - ํ•ญ์ƒ ๋ Œ๋”๋ง (collapsed ์ƒํƒœ๋กœ ์ ‘๊ธฐ/ํŽผ์น˜๊ธฐ ์ œ์–ด) */} + setShowFieldPanel(!showFieldPanel)} + /> + {/* ํ—ค๋” ํˆด๋ฐ” */}
@@ -426,6 +679,30 @@ export const PivotGridComponent: React.FC = ({
+ {/* ํ•„๋“œ ์„ ํƒ๊ธฐ ๋ฒ„ํŠผ */} + {fieldChooser?.enabled !== false && ( + + )} + + {/* ํ•„๋“œ ํŒจ๋„ ํ† ๊ธ€ */} + + {allowExpandAll && ( <> )} + {/* ๋‚ด๋ณด๋‚ด๊ธฐ ๋ฒ„ํŠผ๋“ค */} + {exportConfig?.excel && ( + <> + + + + )} +
+ + {/* ์ฐจํŠธ */} + {showChart && chartConfig && pivotResult && ( + + )} + + {/* ํ•„๋“œ ์„ ํƒ๊ธฐ ๋ชจ๋‹ฌ */} + + + {/* Drill Down ๋ชจ๋‹ฌ */} + setDrillDownData((prev) => ({ ...prev, open }))} + cellData={drillDownData.cellData} + data={data} + fields={fields} + rowFields={rowFields} + columnFields={columnFields} + />
); }; diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx index a0e322d9..f3e9a976 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx @@ -431,14 +431,9 @@ const AreaFieldList: React.FC = ({ ) : ( availableColumns.map((col) => ( -
- {col.column_name} - {col.column_comment && ( - - ({col.column_comment}) - - )} -
+ {col.column_comment + ? `${col.column_name} (${col.column_comment})` + : col.column_name}
)) )} @@ -476,7 +471,8 @@ export const PivotGridConfigPanel: React.FC = ({ const loadTables = async () => { setLoadingTables(true); try { - const response = await apiClient.get("/api/table-management/list"); + // apiClient์˜ baseURL์ด ์ด๋ฏธ /api๋ฅผ ํฌํ•จํ•˜๋ฏ€๋กœ /api ์ œ์™ธ + const response = await apiClient.get("/table-management/tables"); if (response.data.success) { setTables(response.data.data || []); } @@ -499,8 +495,9 @@ export const PivotGridConfigPanel: React.FC = ({ setLoadingColumns(true); try { + // apiClient์˜ baseURL์ด ์ด๋ฏธ /api๋ฅผ ํฌํ•จํ•˜๋ฏ€๋กœ /api ์ œ์™ธ const response = await apiClient.get( - `/api/table-management/columns/${config.dataSource.tableName}` + `/table-management/tables/${config.dataSource.tableName}/columns` ); if (response.data.success) { setColumns(response.data.data || []); @@ -550,14 +547,9 @@ export const PivotGridConfigPanel: React.FC = ({ ์„ ํƒ ์•ˆ ํ•จ {tables.map((table) => ( -
- {table.table_name} - {table.table_comment && ( - - ({table.table_comment}) - - )} -
+ {table.table_comment + ? `${table.table_name} (${table.table_comment})` + : table.table_name}
))} @@ -717,6 +709,270 @@ export const PivotGridConfigPanel: React.FC = ({ + {/* ์ฐจํŠธ ์„ค์ • */} +
+ + +
+
+ + + updateConfig({ + chart: { + ...config.chart, + enabled: v, + type: config.chart?.type || "bar", + position: config.chart?.position || "bottom", + }, + }) + } + /> +
+ + {config.chart?.enabled && ( +
+
+ + +
+ +
+ + + updateConfig({ + chart: { + ...config.chart, + enabled: true, + type: config.chart?.type || "bar", + position: config.chart?.position || "bottom", + height: Number(e.target.value), + }, + }) + } + className="h-8 text-xs" + /> +
+ +
+ + + updateConfig({ + chart: { + ...config.chart, + enabled: true, + type: config.chart?.type || "bar", + position: config.chart?.position || "bottom", + showLegend: v, + }, + }) + } + /> +
+
+ )} +
+
+ + + + {/* ํ•„๋“œ ์„ ํƒ๊ธฐ ์„ค์ • */} +
+ + +
+
+ + + updateConfig({ + fieldChooser: { ...config.fieldChooser, enabled: v }, + }) + } + /> +
+ +
+ + + updateConfig({ + fieldChooser: { ...config.fieldChooser, allowSearch: v }, + }) + } + /> +
+
+
+ + + + {/* ์กฐ๊ฑด๋ถ€ ์„œ์‹ ์„ค์ • */} +
+ + +
+
+ + r.type === "colorScale" + ) || false + } + onCheckedChange={(v) => { + const existingFormats = config.style?.conditionalFormats || []; + const filtered = existingFormats.filter( + (r) => r.type !== "colorScale" + ); + updateConfig({ + style: { + ...config.style, + theme: config.style?.theme || "default", + headerStyle: config.style?.headerStyle || "default", + cellPadding: config.style?.cellPadding || "normal", + borderStyle: config.style?.borderStyle || "light", + conditionalFormats: v + ? [ + ...filtered, + { + id: "colorScale-1", + type: "colorScale" as const, + colorScale: { + minColor: "#ff6b6b", + midColor: "#ffd93d", + maxColor: "#6bcb77", + }, + }, + ] + : filtered, + }, + }); + }} + /> +
+ +
+ + r.type === "dataBar" + ) || false + } + onCheckedChange={(v) => { + const existingFormats = config.style?.conditionalFormats || []; + const filtered = existingFormats.filter( + (r) => r.type !== "dataBar" + ); + updateConfig({ + style: { + ...config.style, + theme: config.style?.theme || "default", + headerStyle: config.style?.headerStyle || "default", + cellPadding: config.style?.cellPadding || "normal", + borderStyle: config.style?.borderStyle || "light", + conditionalFormats: v + ? [ + ...filtered, + { + id: "dataBar-1", + type: "dataBar" as const, + dataBar: { + color: "#3b82f6", + showValue: true, + }, + }, + ] + : filtered, + }, + }); + }} + /> +
+ +
+ + r.type === "iconSet" + ) || false + } + onCheckedChange={(v) => { + const existingFormats = config.style?.conditionalFormats || []; + const filtered = existingFormats.filter( + (r) => r.type !== "iconSet" + ); + updateConfig({ + style: { + ...config.style, + theme: config.style?.theme || "default", + headerStyle: config.style?.headerStyle || "default", + cellPadding: config.style?.cellPadding || "normal", + borderStyle: config.style?.borderStyle || "light", + conditionalFormats: v + ? [ + ...filtered, + { + id: "iconSet-1", + type: "iconSet" as const, + iconSet: { + type: "traffic", + thresholds: [33, 66], + }, + }, + ] + : filtered, + }, + }); + }} + /> +
+ + {config.style?.conditionalFormats && + config.style.conditionalFormats.length > 0 && ( +

+ {config.style.conditionalFormats.length}๊ฐœ์˜ ์กฐ๊ฑด๋ถ€ ์„œ์‹์ด + ์ ์šฉ๋จ +

+ )} +
+
+ + + {/* ํฌ๊ธฐ ์„ค์ • */}
diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx index 7c34192a..8e3563d9 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx @@ -6,6 +6,160 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition import { ComponentCategory } from "@/types/component"; import { PivotGridComponent } from "./PivotGridComponent"; import { PivotGridConfigPanel } from "./PivotGridConfigPanel"; +import { PivotFieldConfig } from "./types"; + +// ==================== ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ (๋ฏธ๋ฆฌ๋ณด๊ธฐ์šฉ) ==================== + +const SAMPLE_DATA = [ + { region: "์„œ์šธ", product: "๋…ธํŠธ๋ถ", quarter: "Q1", sales: 1500000, quantity: 15 }, + { region: "์„œ์šธ", product: "๋…ธํŠธ๋ถ", quarter: "Q2", sales: 1800000, quantity: 18 }, + { region: "์„œ์šธ", product: "๋…ธํŠธ๋ถ", quarter: "Q3", sales: 2100000, quantity: 21 }, + { region: "์„œ์šธ", product: "๋…ธํŠธ๋ถ", quarter: "Q4", sales: 2500000, quantity: 25 }, + { region: "์„œ์šธ", product: "์Šค๋งˆํŠธํฐ", quarter: "Q1", sales: 2000000, quantity: 40 }, + { region: "์„œ์šธ", product: "์Šค๋งˆํŠธํฐ", quarter: "Q2", sales: 2200000, quantity: 44 }, + { region: "์„œ์šธ", product: "์Šค๋งˆํŠธํฐ", quarter: "Q3", sales: 2500000, quantity: 50 }, + { region: "์„œ์šธ", product: "์Šค๋งˆํŠธํฐ", quarter: "Q4", sales: 3000000, quantity: 60 }, + { region: "์„œ์šธ", product: "ํƒœ๋ธ”๋ฆฟ", quarter: "Q1", sales: 800000, quantity: 10 }, + { region: "์„œ์šธ", product: "ํƒœ๋ธ”๋ฆฟ", quarter: "Q2", sales: 900000, quantity: 11 }, + { region: "์„œ์šธ", product: "ํƒœ๋ธ”๋ฆฟ", quarter: "Q3", sales: 1000000, quantity: 12 }, + { region: "์„œ์šธ", product: "ํƒœ๋ธ”๋ฆฟ", quarter: "Q4", sales: 1200000, quantity: 15 }, + { region: "๋ถ€์‚ฐ", product: "๋…ธํŠธ๋ถ", quarter: "Q1", sales: 1000000, quantity: 10 }, + { region: "๋ถ€์‚ฐ", product: "๋…ธํŠธ๋ถ", quarter: "Q2", sales: 1200000, quantity: 12 }, + { region: "๋ถ€์‚ฐ", product: "๋…ธํŠธ๋ถ", quarter: "Q3", sales: 1400000, quantity: 14 }, + { region: "๋ถ€์‚ฐ", product: "๋…ธํŠธ๋ถ", quarter: "Q4", sales: 1600000, quantity: 16 }, + { region: "๋ถ€์‚ฐ", product: "์Šค๋งˆํŠธํฐ", quarter: "Q1", sales: 1500000, quantity: 30 }, + { region: "๋ถ€์‚ฐ", product: "์Šค๋งˆํŠธํฐ", quarter: "Q2", sales: 1700000, quantity: 34 }, + { region: "๋ถ€์‚ฐ", product: "์Šค๋งˆํŠธํฐ", quarter: "Q3", sales: 1900000, quantity: 38 }, + { region: "๋ถ€์‚ฐ", product: "์Šค๋งˆํŠธํฐ", quarter: "Q4", sales: 2200000, quantity: 44 }, + { region: "๋ถ€์‚ฐ", product: "ํƒœ๋ธ”๋ฆฟ", quarter: "Q1", sales: 500000, quantity: 6 }, + { region: "๋ถ€์‚ฐ", product: "ํƒœ๋ธ”๋ฆฟ", quarter: "Q2", sales: 600000, quantity: 7 }, + { region: "๋ถ€์‚ฐ", product: "ํƒœ๋ธ”๋ฆฟ", quarter: "Q3", sales: 700000, quantity: 8 }, + { region: "๋ถ€์‚ฐ", product: "ํƒœ๋ธ”๋ฆฟ", quarter: "Q4", sales: 800000, quantity: 10 }, + { region: "๋Œ€๊ตฌ", product: "๋…ธํŠธ๋ถ", quarter: "Q1", sales: 700000, quantity: 7 }, + { region: "๋Œ€๊ตฌ", product: "๋…ธํŠธ๋ถ", quarter: "Q2", sales: 850000, quantity: 8 }, + { region: "๋Œ€๊ตฌ", product: "๋…ธํŠธ๋ถ", quarter: "Q3", sales: 900000, quantity: 9 }, + { region: "๋Œ€๊ตฌ", product: "๋…ธํŠธ๋ถ", quarter: "Q4", sales: 1100000, quantity: 11 }, + { region: "๋Œ€๊ตฌ", product: "์Šค๋งˆํŠธํฐ", quarter: "Q1", sales: 1000000, quantity: 20 }, + { region: "๋Œ€๊ตฌ", product: "์Šค๋งˆํŠธํฐ", quarter: "Q2", sales: 1200000, quantity: 24 }, + { region: "๋Œ€๊ตฌ", product: "์Šค๋งˆํŠธํฐ", quarter: "Q3", sales: 1300000, quantity: 26 }, + { region: "๋Œ€๊ตฌ", product: "์Šค๋งˆํŠธํฐ", quarter: "Q4", sales: 1500000, quantity: 30 }, + { region: "๋Œ€๊ตฌ", product: "ํƒœ๋ธ”๋ฆฟ", quarter: "Q1", sales: 400000, quantity: 5 }, + { region: "๋Œ€๊ตฌ", product: "ํƒœ๋ธ”๋ฆฟ", quarter: "Q2", sales: 450000, quantity: 5 }, + { region: "๋Œ€๊ตฌ", product: "ํƒœ๋ธ”๋ฆฟ", quarter: "Q3", sales: 500000, quantity: 6 }, + { region: "๋Œ€๊ตฌ", product: "ํƒœ๋ธ”๋ฆฟ", quarter: "Q4", sales: 600000, quantity: 7 }, +]; + +const SAMPLE_FIELDS: PivotFieldConfig[] = [ + { + field: "region", + caption: "์ง€์—ญ", + area: "row", + areaIndex: 0, + dataType: "string", + visible: true, + }, + { + field: "product", + caption: "์ œํ’ˆ", + area: "row", + areaIndex: 1, + dataType: "string", + visible: true, + }, + { + field: "quarter", + caption: "๋ถ„๊ธฐ", + area: "column", + areaIndex: 0, + dataType: "string", + visible: true, + }, + { + field: "sales", + caption: "๋งค์ถœ", + area: "data", + areaIndex: 0, + dataType: "number", + summaryType: "sum", + format: { type: "number", precision: 0 }, + visible: true, + }, +]; + +/** + * PivotGrid ๋ž˜ํผ ์ปดํฌ๋„ŒํŠธ (๋””์ž์ธ ๋ชจ๋“œ์—์„œ ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ ์ฃผ์ž…) + */ +const PivotGridWrapper: React.FC = (props) => { + // ์ปดํฌ๋„ŒํŠธ ์„ค์ •์—์„œ ๊ฐ’ ์ถ”์ถœ + const componentConfig = props.componentConfig || props.config || {}; + const configFields = componentConfig.fields || props.fields; + const configData = props.data; + + // ๋””๋ฒ„๊น… ๋กœ๊ทธ + console.log("๐Ÿ”ท PivotGridWrapper props:", { + isDesignMode: props.isDesignMode, + isInteractive: props.isInteractive, + hasComponentConfig: !!props.componentConfig, + hasConfig: !!props.config, + hasData: !!configData, + dataLength: configData?.length, + hasFields: !!configFields, + fieldsLength: configFields?.length, + }); + + // ๋””์ž์ธ ๋ชจ๋“œ ํŒ๋‹จ: + // 1. isDesignMode === true + // 2. isInteractive === false (ํŽธ์ง‘ ๋ชจ๋“œ) + // 3. ๋ฐ์ดํ„ฐ๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ + const isDesignMode = props.isDesignMode === true || props.isInteractive === false; + const hasValidData = configData && Array.isArray(configData) && configData.length > 0; + const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0; + + // ๋””์ž์ธ ๋ชจ๋“œ์ด๊ฑฐ๋‚˜ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์œผ๋ฉด ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ + const usePreviewData = isDesignMode || !hasValidData; + + // ์ตœ์ข… ๋ฐ์ดํ„ฐ/ํ•„๋“œ ๊ฒฐ์ • + const finalData = usePreviewData ? SAMPLE_DATA : configData; + const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS; + const finalTitle = usePreviewData + ? (componentConfig.title || props.title || "ํ”ผ๋ฒ— ๊ทธ๋ฆฌ๋“œ") + " (๋ฏธ๋ฆฌ๋ณด๊ธฐ)" + : (componentConfig.title || props.title); + + console.log("๐Ÿ”ท PivotGridWrapper final:", { + isDesignMode, + usePreviewData, + finalDataLength: finalData?.length, + finalFieldsLength: finalFields?.length, + }); + + // ์ด๊ณ„ ์„ค์ • + const totalsConfig = componentConfig.totals || props.totals || { + showRowGrandTotals: true, + showColumnGrandTotals: true, + showRowTotals: true, + showColumnTotals: true, + }; + + return ( + + ); +}; /** * PivotGrid ์ปดํฌ๋„ŒํŠธ ์ •์˜ @@ -17,13 +171,15 @@ const PivotGridDefinition = createComponentDefinition({ description: "๋‹ค์ฐจ์› ๋ฐ์ดํ„ฐ ๋ถ„์„์„ ์œ„ํ•œ ํ”ผ๋ฒ— ํ…Œ์ด๋ธ” ์ปดํฌ๋„ŒํŠธ", category: ComponentCategory.DISPLAY, webType: "text", - component: PivotGridComponent, + component: PivotGridWrapper, // ๋ž˜ํผ ์ปดํฌ๋„ŒํŠธ ์‚ฌ์šฉ defaultConfig: { dataSource: { type: "table", tableName: "", }, - fields: [], + fields: SAMPLE_FIELDS, + // ๋ฏธ๋ฆฌ๋ณด๊ธฐ์šฉ ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ + sampleData: SAMPLE_DATA, totals: { showRowGrandTotals: true, showColumnGrandTotals: true, @@ -61,9 +217,75 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer { static componentDefinition = PivotGridDefinition; render(): React.ReactElement { + const props = this.props as any; + + // ์ปดํฌ๋„ŒํŠธ ์„ค์ •์—์„œ ๊ฐ’ ์ถ”์ถœ + const componentConfig = props.componentConfig || props.config || {}; + const configFields = componentConfig.fields || props.fields; + const configData = props.data; + + // ๋””๋ฒ„๊น… ๋กœ๊ทธ + console.log("๐Ÿ”ท PivotGridRenderer props:", { + isDesignMode: props.isDesignMode, + isInteractive: props.isInteractive, + hasComponentConfig: !!props.componentConfig, + hasConfig: !!props.config, + hasData: !!configData, + dataLength: configData?.length, + hasFields: !!configFields, + fieldsLength: configFields?.length, + }); + + // ๋””์ž์ธ ๋ชจ๋“œ ํŒ๋‹จ: + // 1. isDesignMode === true + // 2. isInteractive === false (ํŽธ์ง‘ ๋ชจ๋“œ) + // 3. ๋ฐ์ดํ„ฐ๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ + const isDesignMode = props.isDesignMode === true || props.isInteractive === false; + const hasValidData = configData && Array.isArray(configData) && configData.length > 0; + const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0; + + // ๋””์ž์ธ ๋ชจ๋“œ์ด๊ฑฐ๋‚˜ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์œผ๋ฉด ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ + const usePreviewData = isDesignMode || !hasValidData; + + // ์ตœ์ข… ๋ฐ์ดํ„ฐ/ํ•„๋“œ ๊ฒฐ์ • + const finalData = usePreviewData ? SAMPLE_DATA : configData; + const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS; + const finalTitle = usePreviewData + ? (componentConfig.title || props.title || "ํ”ผ๋ฒ— ๊ทธ๋ฆฌ๋“œ") + " (๋ฏธ๋ฆฌ๋ณด๊ธฐ)" + : (componentConfig.title || props.title); + + console.log("๐Ÿ”ท PivotGridRenderer final:", { + isDesignMode, + usePreviewData, + finalDataLength: finalData?.length, + finalFieldsLength: finalFields?.length, + }); + + // ์ด๊ณ„ ์„ค์ • + const totalsConfig = componentConfig.totals || props.totals || { + showRowGrandTotals: true, + showColumnGrandTotals: true, + showRowTotals: true, + showColumnTotals: true, + }; + return ( ); } diff --git a/frontend/lib/registry/components/pivot-grid/components/DrillDownModal.tsx b/frontend/lib/registry/components/pivot-grid/components/DrillDownModal.tsx new file mode 100644 index 00000000..994d782f --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/DrillDownModal.tsx @@ -0,0 +1,429 @@ +"use client"; + +/** + * DrillDownModal ์ปดํฌ๋„ŒํŠธ + * ํ”ผ๋ฒ— ์…€ ํด๋ฆญ ์‹œ ํ•ด๋‹น ์…€์˜ ์ƒ์„ธ ์›๋ณธ ๋ฐ์ดํ„ฐ๋ฅผ ํ‘œ์‹œํ•˜๋Š” ๋ชจ๋‹ฌ + */ + +import React, { useState, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { PivotCellData, PivotFieldConfig } from "../types"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Search, + Download, + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, + ArrowUpDown, + ArrowUp, + ArrowDown, +} from "lucide-react"; + +// ==================== ํƒ€์ž… ==================== + +interface DrillDownModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + cellData: PivotCellData | null; + data: any[]; // ์ „์ฒด ์›๋ณธ ๋ฐ์ดํ„ฐ + fields: PivotFieldConfig[]; + rowFields: PivotFieldConfig[]; + columnFields: PivotFieldConfig[]; +} + +interface SortConfig { + field: string; + direction: "asc" | "desc"; +} + +// ==================== ๋ฉ”์ธ ์ปดํฌ๋„ŒํŠธ ==================== + +export const DrillDownModal: React.FC = ({ + open, + onOpenChange, + cellData, + data, + fields, + rowFields, + columnFields, +}) => { + const [searchQuery, setSearchQuery] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [sortConfig, setSortConfig] = useState(null); + + // ๋“œ๋ฆด๋‹ค์šด ๋ฐ์ดํ„ฐ ํ•„ํ„ฐ๋ง + const filteredData = useMemo(() => { + if (!cellData || !data) return []; + + // ํ–‰/์—ด ๊ฒฝ๋กœ์— ํ•ด๋‹นํ•˜๋Š” ๋ฐ์ดํ„ฐ ํ•„ํ„ฐ๋ง + let result = data.filter((row) => { + // ํ–‰ ๊ฒฝ๋กœ ๋งค์นญ + for (let i = 0; i < cellData.rowPath.length; i++) { + const field = rowFields[i]; + if (field && String(row[field.field]) !== cellData.rowPath[i]) { + return false; + } + } + + // ์—ด ๊ฒฝ๋กœ ๋งค์นญ + for (let i = 0; i < cellData.columnPath.length; i++) { + const field = columnFields[i]; + if (field && String(row[field.field]) !== cellData.columnPath[i]) { + return false; + } + } + + return true; + }); + + // ๊ฒ€์ƒ‰ ํ•„ํ„ฐ + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter((row) => + Object.values(row).some((val) => + String(val).toLowerCase().includes(query) + ) + ); + } + + // ์ •๋ ฌ + if (sortConfig) { + result = [...result].sort((a, b) => { + const aVal = a[sortConfig.field]; + const bVal = b[sortConfig.field]; + + if (aVal === null || aVal === undefined) return 1; + if (bVal === null || bVal === undefined) return -1; + + let comparison = 0; + if (typeof aVal === "number" && typeof bVal === "number") { + comparison = aVal - bVal; + } else { + comparison = String(aVal).localeCompare(String(bVal)); + } + + return sortConfig.direction === "asc" ? comparison : -comparison; + }); + } + + return result; + }, [cellData, data, rowFields, columnFields, searchQuery, sortConfig]); + + // ํŽ˜์ด์ง€๋„ค์ด์…˜ + const totalPages = Math.ceil(filteredData.length / pageSize); + const paginatedData = useMemo(() => { + const start = (currentPage - 1) * pageSize; + return filteredData.slice(start, start + pageSize); + }, [filteredData, currentPage, pageSize]); + + // ํ‘œ์‹œํ•  ์ปฌ๋Ÿผ ๊ฒฐ์ • + const displayColumns = useMemo(() => { + // ๋ชจ๋“  ํ•„๋“œ์˜ field๋ช… ์ˆ˜์ง‘ + const fieldNames = new Set(); + + // fields์—์„œ ๊ฐ€์ ธ์˜ค๊ธฐ + fields.forEach((f) => fieldNames.add(f.field)); + + // ๋ฐ์ดํ„ฐ์—์„œ ์ถ”๊ฐ€ ์ปฌ๋Ÿผ ๊ฐ€์ ธ์˜ค๊ธฐ + if (data.length > 0) { + Object.keys(data[0]).forEach((key) => fieldNames.add(key)); + } + + return Array.from(fieldNames).map((fieldName) => { + const fieldConfig = fields.find((f) => f.field === fieldName); + return { + field: fieldName, + caption: fieldConfig?.caption || fieldName, + dataType: fieldConfig?.dataType || "string", + }; + }); + }, [fields, data]); + + // ์ •๋ ฌ ํ† ๊ธ€ + const handleSort = (field: string) => { + setSortConfig((prev) => { + if (!prev || prev.field !== field) { + return { field, direction: "asc" }; + } + if (prev.direction === "asc") { + return { field, direction: "desc" }; + } + return null; + }); + }; + + // CSV ๋‚ด๋ณด๋‚ด๊ธฐ + const handleExportCSV = () => { + if (filteredData.length === 0) return; + + const headers = displayColumns.map((c) => c.caption); + const rows = filteredData.map((row) => + displayColumns.map((c) => { + const val = row[c.field]; + if (val === null || val === undefined) return ""; + if (typeof val === "string" && val.includes(",")) { + return `"${val}"`; + } + return String(val); + }) + ); + + const csv = [headers.join(","), ...rows.map((r) => r.join(","))].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 = `drilldown_${cellData?.rowPath.join("_") || "data"}.csv`; + link.click(); + }; + + // ํŽ˜์ด์ง€ ๋ณ€๊ฒฝ + const goToPage = (page: number) => { + setCurrentPage(Math.max(1, Math.min(page, totalPages))); + }; + + // ๊ฒฝ๋กœ ํ‘œ์‹œ + const pathDisplay = cellData + ? [ + ...(cellData.rowPath.length > 0 + ? [`ํ–‰: ${cellData.rowPath.join(" > ")}`] + : []), + ...(cellData.columnPath.length > 0 + ? [`์—ด: ${cellData.columnPath.join(" > ")}`] + : []), + ].join(" | ") + : ""; + + return ( + + + + ์ƒ์„ธ ๋ฐ์ดํ„ฐ + + {pathDisplay || "์„ ํƒํ•œ ์…€์˜ ์›๋ณธ ๋ฐ์ดํ„ฐ"} + + ({filteredData.length}๊ฑด) + + + + + {/* ํˆด๋ฐ” */} +
+
+ + { + setSearchQuery(e.target.value); + setCurrentPage(1); + }} + className="pl-9 h-9" + /> +
+ + + + +
+ + {/* ํ…Œ์ด๋ธ” */} + +
+ + + + {displayColumns.map((col) => ( + handleSort(col.field)} + > +
+ {col.caption} + {sortConfig?.field === col.field ? ( + sortConfig.direction === "asc" ? ( + + ) : ( + + ) + ) : ( + + )} +
+
+ ))} +
+
+ + {paginatedData.length === 0 ? ( + + + ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. + + + ) : ( + paginatedData.map((row, idx) => ( + + {displayColumns.map((col) => ( + + {formatCellValue(row[col.field], col.dataType)} + + ))} + + )) + )} + +
+
+
+ + {/* ํŽ˜์ด์ง€๋„ค์ด์…˜ */} + {totalPages > 1 && ( +
+
+ {(currentPage - 1) * pageSize + 1} -{" "} + {Math.min(currentPage * pageSize, filteredData.length)} /{" "} + {filteredData.length}๊ฑด +
+ +
+ + + + + {currentPage} / {totalPages} + + + + +
+
+ )} +
+
+ ); +}; + +// ==================== ์œ ํ‹ธ๋ฆฌํ‹ฐ ==================== + +function formatCellValue(value: any, dataType: string): string { + if (value === null || value === undefined) return "-"; + + if (dataType === "number") { + const num = Number(value); + if (isNaN(num)) return String(value); + return num.toLocaleString(); + } + + if (dataType === "date") { + try { + const date = new Date(value); + if (!isNaN(date.getTime())) { + return date.toLocaleDateString("ko-KR"); + } + } catch { + // ๋ณ€ํ™˜ ์‹คํŒจ ์‹œ ์›๋ณธ ๋ฐ˜ํ™˜ + } + } + + return String(value); +} + +export default DrillDownModal; + diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx new file mode 100644 index 00000000..ec194a12 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx @@ -0,0 +1,441 @@ +"use client"; + +/** + * FieldChooser ์ปดํฌ๋„ŒํŠธ + * ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ํ•„๋“œ ๋ชฉ๋ก์„ ํ‘œ์‹œํ•˜๊ณ  ์˜์—ญ์— ๋ฐฐ์น˜ํ•  ์ˆ˜ ์žˆ๋Š” ๋ชจ๋‹ฌ + */ + +import React, { useState, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { PivotFieldConfig, PivotAreaType, AggregationType, SummaryDisplayMode } from "../types"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Search, + Filter, + Columns, + Rows, + BarChart3, + GripVertical, + Plus, + Minus, + Type, + Hash, + Calendar, + ToggleLeft, +} from "lucide-react"; + +// ==================== ํƒ€์ž… ==================== + +interface AvailableField { + field: string; + caption: string; + dataType: "string" | "number" | "date" | "boolean"; + isSelected: boolean; + currentArea?: PivotAreaType; +} + +interface FieldChooserProps { + open: boolean; + onOpenChange: (open: boolean) => void; + availableFields: AvailableField[]; + selectedFields: PivotFieldConfig[]; + onFieldsChange: (fields: PivotFieldConfig[]) => void; +} + +// ==================== ์˜์—ญ ์„ค์ • ==================== + +const AREA_OPTIONS: { + value: PivotAreaType | "none"; + label: string; + icon: React.ReactNode; +}[] = [ + { value: "none", label: "์‚ฌ์šฉ ์•ˆํ•จ", icon: }, + { value: "filter", label: "ํ•„ํ„ฐ", icon: }, + { value: "row", label: "ํ–‰", icon: }, + { value: "column", label: "์—ด", icon: }, + { value: "data", label: "๋ฐ์ดํ„ฐ", icon: }, +]; + +const SUMMARY_OPTIONS: { value: AggregationType; label: string }[] = [ + { value: "sum", label: "ํ•ฉ๊ณ„" }, + { value: "count", label: "๊ฐœ์ˆ˜" }, + { value: "avg", label: "ํ‰๊ท " }, + { value: "min", label: "์ตœ์†Œ" }, + { value: "max", label: "์ตœ๋Œ€" }, + { value: "countDistinct", label: "๊ณ ์œ  ๊ฐœ์ˆ˜" }, +]; + +const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [ + { value: "absoluteValue", label: "์ ˆ๋Œ€๊ฐ’" }, + { value: "percentOfRowTotal", label: "ํ–‰ ์ด๊ณ„ %" }, + { value: "percentOfColumnTotal", label: "์—ด ์ด๊ณ„ %" }, + { value: "percentOfGrandTotal", label: "์ „์ฒด ์ด๊ณ„ %" }, + { value: "runningTotalByRow", label: "ํ–‰ ๋ˆ„๊ณ„" }, + { value: "runningTotalByColumn", label: "์—ด ๋ˆ„๊ณ„" }, + { value: "differenceFromPrevious", label: "์ด์ „ ๋Œ€๋น„ ์ฐจ์ด" }, + { value: "percentDifferenceFromPrevious", label: "์ด์ „ ๋Œ€๋น„ % ์ฐจ์ด" }, +]; + +const DATA_TYPE_ICONS: Record = { + string: , + number: , + date: , + boolean: , +}; + +// ==================== ํ•„๋“œ ์•„์ดํ…œ ==================== + +interface FieldItemProps { + field: AvailableField; + config?: PivotFieldConfig; + onAreaChange: (area: PivotAreaType | "none") => void; + onSummaryChange?: (summary: AggregationType) => void; + onDisplayModeChange?: (displayMode: SummaryDisplayMode) => void; +} + +const FieldItem: React.FC = ({ + field, + config, + onAreaChange, + onSummaryChange, + onDisplayModeChange, +}) => { + const currentArea = config?.area || "none"; + const isSelected = currentArea !== "none"; + + return ( +
+ {/* ๋ฐ์ดํ„ฐ ํƒ€์ž… ์•„์ด์ฝ˜ */} +
+ {DATA_TYPE_ICONS[field.dataType] || } +
+ + {/* ํ•„๋“œ๋ช… */} +
+
{field.caption}
+
+ {field.field} +
+
+ + {/* ์˜์—ญ ์„ ํƒ */} + + + {/* ์ง‘๊ณ„ ํ•จ์ˆ˜ ์„ ํƒ (๋ฐ์ดํ„ฐ ์˜์—ญ์ธ ๊ฒฝ์šฐ) */} + {currentArea === "data" && onSummaryChange && ( + + )} + + {/* ํ‘œ์‹œ ๋ชจ๋“œ ์„ ํƒ (๋ฐ์ดํ„ฐ ์˜์—ญ์ธ ๊ฒฝ์šฐ) */} + {currentArea === "data" && onDisplayModeChange && ( + + )} +
+ ); +}; + +// ==================== ๋ฉ”์ธ ์ปดํฌ๋„ŒํŠธ ==================== + +export const FieldChooser: React.FC = ({ + open, + onOpenChange, + availableFields, + selectedFields, + onFieldsChange, +}) => { + const [searchQuery, setSearchQuery] = useState(""); + const [filterType, setFilterType] = useState<"all" | "selected" | "unselected">( + "all" + ); + + // ํ•„ํ„ฐ๋ง๋œ ํ•„๋“œ ๋ชฉ๋ก + const filteredFields = useMemo(() => { + let result = availableFields; + + // ๊ฒ€์ƒ‰์–ด ํ•„ํ„ฐ + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter( + (f) => + f.caption.toLowerCase().includes(query) || + f.field.toLowerCase().includes(query) + ); + } + + // ์„ ํƒ ์ƒํƒœ ํ•„ํ„ฐ + if (filterType === "selected") { + result = result.filter((f) => + selectedFields.some((sf) => sf.field === f.field && sf.visible !== false) + ); + } else if (filterType === "unselected") { + result = result.filter( + (f) => + !selectedFields.some( + (sf) => sf.field === f.field && sf.visible !== false + ) + ); + } + + return result; + }, [availableFields, selectedFields, searchQuery, filterType]); + + // ํ•„๋“œ ์˜์—ญ ๋ณ€๊ฒฝ + const handleAreaChange = ( + field: AvailableField, + area: PivotAreaType | "none" + ) => { + const existingConfig = selectedFields.find((f) => f.field === field.field); + + if (area === "none") { + // ํ•„๋“œ ์ œ๊ฑฐ ๋˜๋Š” ์ˆจ๊ธฐ๊ธฐ + if (existingConfig) { + const newFields = selectedFields.map((f) => + f.field === field.field ? { ...f, visible: false } : f + ); + onFieldsChange(newFields); + } + } else { + // ํ•„๋“œ ์ถ”๊ฐ€ ๋˜๋Š” ์˜์—ญ ๋ณ€๊ฒฝ + if (existingConfig) { + const newFields = selectedFields.map((f) => + f.field === field.field + ? { ...f, area, visible: true } + : f + ); + onFieldsChange(newFields); + } else { + // ์ƒˆ ํ•„๋“œ ์ถ”๊ฐ€ + const newField: PivotFieldConfig = { + field: field.field, + caption: field.caption, + area, + dataType: field.dataType, + visible: true, + summaryType: area === "data" ? "sum" : undefined, + areaIndex: selectedFields.filter((f) => f.area === area).length, + }; + onFieldsChange([...selectedFields, newField]); + } + } + }; + + // ์ง‘๊ณ„ ํ•จ์ˆ˜ ๋ณ€๊ฒฝ + const handleSummaryChange = ( + field: AvailableField, + summaryType: AggregationType + ) => { + const newFields = selectedFields.map((f) => + f.field === field.field ? { ...f, summaryType } : f + ); + onFieldsChange(newFields); + }; + + // ํ‘œ์‹œ ๋ชจ๋“œ ๋ณ€๊ฒฝ + const handleDisplayModeChange = ( + field: AvailableField, + displayMode: SummaryDisplayMode + ) => { + const newFields = selectedFields.map((f) => + f.field === field.field ? { ...f, summaryDisplayMode: displayMode } : f + ); + onFieldsChange(newFields); + }; + + // ๋ชจ๋“  ํ•„๋“œ ์„ ํƒ ํ•ด์ œ + const handleClearAll = () => { + const newFields = selectedFields.map((f) => ({ ...f, visible: false })); + onFieldsChange(newFields); + }; + + // ํ†ต๊ณ„ + const stats = useMemo(() => { + const visible = selectedFields.filter((f) => f.visible !== false); + return { + total: availableFields.length, + selected: visible.length, + filter: visible.filter((f) => f.area === "filter").length, + row: visible.filter((f) => f.area === "row").length, + column: visible.filter((f) => f.area === "column").length, + data: visible.filter((f) => f.area === "data").length, + }; + }, [availableFields, selectedFields]); + + return ( + + + + ํ•„๋“œ ์„ ํƒ๊ธฐ + + ํ”ผ๋ฒ— ํ…Œ์ด๋ธ”์— ํ‘œ์‹œํ•  ํ•„๋“œ๋ฅผ ์„ ํƒํ•˜๊ณ  ์˜์—ญ์„ ์ง€์ •ํ•˜์„ธ์š”. + + + + {/* ํ†ต๊ณ„ */} +
+ ์ „์ฒด: {stats.total} + + ์„ ํƒ๋จ: {stats.selected} + + ํ•„ํ„ฐ: {stats.filter} + ํ–‰: {stats.row} + ์—ด: {stats.column} + ๋ฐ์ดํ„ฐ: {stats.data} +
+ + {/* ๊ฒ€์ƒ‰ ๋ฐ ํ•„ํ„ฐ */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9 h-9" + /> +
+ + + + +
+ + {/* ํ•„๋“œ ๋ชฉ๋ก */} + +
+ {filteredFields.length === 0 ? ( +
+ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. +
+ ) : ( + filteredFields.map((field) => { + const config = selectedFields.find( + (f) => f.field === field.field && f.visible !== false + ); + return ( + handleAreaChange(field, area)} + onSummaryChange={ + config?.area === "data" + ? (summary) => handleSummaryChange(field, summary) + : undefined + } + onDisplayModeChange={ + config?.area === "data" + ? (mode) => handleDisplayModeChange(field, mode) + : undefined + } + /> + ); + }) + )} +
+
+ + {/* ํ‘ธํ„ฐ */} +
+ +
+
+
+ ); +}; + +export default FieldChooser; + diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx new file mode 100644 index 00000000..063b4c6c --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx @@ -0,0 +1,551 @@ +"use client"; + +/** + * FieldPanel ์ปดํฌ๋„ŒํŠธ + * ํ”ผ๋ฒ— ๊ทธ๋ฆฌ๋“œ ์ƒ๋‹จ์˜ ํ•„๋“œ ๋ฐฐ์น˜ ์˜์—ญ (ํ•„ํ„ฐ, ์—ด, ํ–‰, ๋ฐ์ดํ„ฐ) + * ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ์œผ๋กœ ํ•„๋“œ ์žฌ๋ฐฐ์น˜ ๊ฐ€๋Šฅ + */ + +import React, { useState } from "react"; +import { + DndContext, + DragOverlay, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragStartEvent, + DragEndEvent, + DragOverEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + horizontalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { cn } from "@/lib/utils"; +import { PivotFieldConfig, PivotAreaType } from "../types"; +import { + X, + Filter, + Columns, + Rows, + BarChart3, + GripVertical, + ChevronDown, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +// ==================== ํƒ€์ž… ==================== + +interface FieldPanelProps { + fields: PivotFieldConfig[]; + onFieldsChange: (fields: PivotFieldConfig[]) => void; + onFieldRemove?: (field: PivotFieldConfig) => void; + onFieldSettingsChange?: (field: PivotFieldConfig) => void; + collapsed?: boolean; + onToggleCollapse?: () => void; +} + +interface FieldChipProps { + field: PivotFieldConfig; + onRemove: () => void; + onSettingsChange?: (field: PivotFieldConfig) => void; +} + +interface DroppableAreaProps { + area: PivotAreaType; + fields: PivotFieldConfig[]; + title: string; + icon: React.ReactNode; + onFieldRemove: (field: PivotFieldConfig) => void; + onFieldSettingsChange?: (field: PivotFieldConfig) => void; + isOver?: boolean; +} + +// ==================== ์˜์—ญ ์„ค์ • ==================== + +const AREA_CONFIG: Record< + PivotAreaType, + { title: string; icon: React.ReactNode; color: string } +> = { + filter: { + title: "ํ•„ํ„ฐ", + icon: , + color: "bg-orange-50 border-orange-200 dark:bg-orange-950/20 dark:border-orange-800", + }, + column: { + title: "์—ด", + icon: , + color: "bg-blue-50 border-blue-200 dark:bg-blue-950/20 dark:border-blue-800", + }, + row: { + title: "ํ–‰", + icon: , + color: "bg-green-50 border-green-200 dark:bg-green-950/20 dark:border-green-800", + }, + data: { + title: "๋ฐ์ดํ„ฐ", + icon: , + color: "bg-purple-50 border-purple-200 dark:bg-purple-950/20 dark:border-purple-800", + }, +}; + +// ==================== ํ•„๋“œ ์นฉ (๋“œ๋ž˜๊ทธ ๊ฐ€๋Šฅ) ==================== + +const SortableFieldChip: React.FC = ({ + field, + onRemove, + onSettingsChange, +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: `${field.area}-${field.field}` }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ {/* ๋“œ๋ž˜๊ทธ ํ•ธ๋“ค */} + + + {/* ํ•„๋“œ ๋ผ๋ฒจ */} + + + + + + {field.area === "data" && ( + <> + + onSettingsChange?.({ ...field, summaryType: "sum" }) + } + > + ํ•ฉ๊ณ„ + + + onSettingsChange?.({ ...field, summaryType: "count" }) + } + > + ๊ฐœ์ˆ˜ + + + onSettingsChange?.({ ...field, summaryType: "avg" }) + } + > + ํ‰๊ท  + + + onSettingsChange?.({ ...field, summaryType: "min" }) + } + > + ์ตœ์†Œ + + + onSettingsChange?.({ ...field, summaryType: "max" }) + } + > + ์ตœ๋Œ€ + + + + )} + + onSettingsChange?.({ + ...field, + sortOrder: field.sortOrder === "asc" ? "desc" : "asc", + }) + } + > + {field.sortOrder === "asc" ? "๋‚ด๋ฆผ์ฐจ์ˆœ ์ •๋ ฌ" : "์˜ค๋ฆ„์ฐจ์ˆœ ์ •๋ ฌ"} + + + onSettingsChange?.({ ...field, visible: false })} + > + ํ•„๋“œ ์ˆจ๊ธฐ๊ธฐ + + + + + {/* ์‚ญ์ œ ๋ฒ„ํŠผ */} + +
+ ); +}; + +// ==================== ๋“œ๋กญ ์˜์—ญ ==================== + +const DroppableArea: React.FC = ({ + area, + fields, + title, + icon, + onFieldRemove, + onFieldSettingsChange, + isOver, +}) => { + const config = AREA_CONFIG[area]; + const areaFields = fields.filter((f) => f.area === area && f.visible !== false); + const fieldIds = areaFields.map((f) => `${area}-${f.field}`); + + return ( +
+ {/* ์˜์—ญ ํ—ค๋” */} +
+ {icon} + {title} + {areaFields.length > 0 && ( + + {areaFields.length} + + )} +
+ + {/* ํ•„๋“œ ๋ชฉ๋ก */} + +
+ {areaFields.length === 0 ? ( + + ํ•„๋“œ๋ฅผ ์—ฌ๊ธฐ๋กœ ๋“œ๋ž˜๊ทธ + + ) : ( + areaFields.map((field) => ( + onFieldRemove(field)} + onSettingsChange={onFieldSettingsChange} + /> + )) + )} +
+
+
+ ); +}; + +// ==================== ์œ ํ‹ธ๋ฆฌํ‹ฐ ==================== + +function getSummaryLabel(type: string): string { + const labels: Record = { + sum: "ํ•ฉ๊ณ„", + count: "๊ฐœ์ˆ˜", + avg: "ํ‰๊ท ", + min: "์ตœ์†Œ", + max: "์ตœ๋Œ€", + countDistinct: "๊ณ ์œ ", + }; + return labels[type] || type; +} + +// ==================== ๋ฉ”์ธ ์ปดํฌ๋„ŒํŠธ ==================== + +export const FieldPanel: React.FC = ({ + fields, + onFieldsChange, + onFieldRemove, + onFieldSettingsChange, + collapsed = false, + onToggleCollapse, +}) => { + const [activeId, setActiveId] = useState(null); + const [overArea, setOverArea] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + // ๋“œ๋ž˜๊ทธ ์‹œ์ž‘ + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id as string); + }; + + // ๋“œ๋ž˜๊ทธ ์˜ค๋ฒ„ + const handleDragOver = (event: DragOverEvent) => { + const { over } = event; + if (!over) { + setOverArea(null); + return; + } + + // ๋“œ๋กญ ์˜์—ญ ๊ฐ์ง€ + const overId = over.id as string; + const targetArea = overId.split("-")[0] as PivotAreaType; + if (["filter", "column", "row", "data"].includes(targetArea)) { + setOverArea(targetArea); + } + }; + + // ๋“œ๋ž˜๊ทธ ์ข…๋ฃŒ + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + setOverArea(null); + + if (!over) return; + + const activeId = active.id as string; + const overId = over.id as string; + + // ํ•„๋“œ ์ •๋ณด ํŒŒ์‹ฑ + const [sourceArea, sourceField] = activeId.split("-") as [ + PivotAreaType, + string + ]; + const [targetArea] = overId.split("-") as [PivotAreaType, string]; + + // ๊ฐ™์€ ์˜์—ญ ๋‚ด ์ •๋ ฌ + if (sourceArea === targetArea) { + const areaFields = fields.filter((f) => f.area === sourceArea); + const sourceIndex = areaFields.findIndex((f) => f.field === sourceField); + const targetIndex = areaFields.findIndex( + (f) => `${f.area}-${f.field}` === overId + ); + + if (sourceIndex !== targetIndex && targetIndex >= 0) { + // ์ˆœ์„œ ๋ณ€๊ฒฝ + const newFields = [...fields]; + const fieldToMove = newFields.find( + (f) => f.field === sourceField && f.area === sourceArea + ); + if (fieldToMove) { + fieldToMove.areaIndex = targetIndex; + // ๋‹ค๋ฅธ ํ•„๋“œ๋“ค ์ธ๋ฑ์Šค ์กฐ์ • + newFields + .filter((f) => f.area === sourceArea && f.field !== sourceField) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)) + .forEach((f, idx) => { + f.areaIndex = idx >= targetIndex ? idx + 1 : idx; + }); + } + onFieldsChange(newFields); + } + return; + } + + // ๋‹ค๋ฅธ ์˜์—ญ์œผ๋กœ ์ด๋™ + if (["filter", "column", "row", "data"].includes(targetArea)) { + const newFields = fields.map((f) => { + if (f.field === sourceField && f.area === sourceArea) { + return { + ...f, + area: targetArea as PivotAreaType, + areaIndex: fields.filter((ff) => ff.area === targetArea).length, + }; + } + return f; + }); + onFieldsChange(newFields); + } + }; + + // ํ•„๋“œ ์ œ๊ฑฐ + const handleFieldRemove = (field: PivotFieldConfig) => { + if (onFieldRemove) { + onFieldRemove(field); + } else { + // ๊ธฐ๋ณธ ๋™์ž‘: visible์„ false๋กœ ์„ค์ • + const newFields = fields.map((f) => + f.field === field.field && f.area === field.area + ? { ...f, visible: false } + : f + ); + onFieldsChange(newFields); + } + }; + + // ํ•„๋“œ ์„ค์ • ๋ณ€๊ฒฝ + const handleFieldSettingsChange = (updatedField: PivotFieldConfig) => { + if (onFieldSettingsChange) { + onFieldSettingsChange(updatedField); + } + const newFields = fields.map((f) => + f.field === updatedField.field && f.area === updatedField.area + ? updatedField + : f + ); + onFieldsChange(newFields); + }; + + // ํ™œ์„ฑ ํ•„๋“œ ์ฐพ๊ธฐ (๋“œ๋ž˜๊ทธ ์ค‘์ธ ํ•„๋“œ) + const activeField = activeId + ? fields.find((f) => `${f.area}-${f.field}` === activeId) + : null; + + if (collapsed) { + return ( +
+ +
+ ); + } + + return ( + +
+ {/* 2x2 ๊ทธ๋ฆฌ๋“œ๋กœ ์˜์—ญ ๋ฐฐ์น˜ */} +
+ {/* ํ•„ํ„ฐ ์˜์—ญ */} + + + {/* ์—ด ์˜์—ญ */} + + + {/* ํ–‰ ์˜์—ญ */} + + + {/* ๋ฐ์ดํ„ฐ ์˜์—ญ */} + +
+ + {/* ์ ‘๊ธฐ ๋ฒ„ํŠผ */} + {onToggleCollapse && ( +
+ +
+ )} +
+ + {/* ๋“œ๋ž˜๊ทธ ์˜ค๋ฒ„๋ ˆ์ด */} + + {activeField ? ( +
+ + {activeField.caption} +
+ ) : null} +
+
+ ); +}; + +export default FieldPanel; + diff --git a/frontend/lib/registry/components/pivot-grid/components/FilterPopup.tsx b/frontend/lib/registry/components/pivot-grid/components/FilterPopup.tsx new file mode 100644 index 00000000..e3185f5a --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/FilterPopup.tsx @@ -0,0 +1,265 @@ +"use client"; + +/** + * FilterPopup ์ปดํฌ๋„ŒํŠธ + * ํ”ผ๋ฒ— ํ•„๋“œ์˜ ๊ฐ’์„ ํ•„ํ„ฐ๋งํ•˜๋Š” ํŒ์—… + */ + +import React, { useState, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { PivotFieldConfig } from "../types"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Label } from "@/components/ui/label"; +import { + Search, + Filter, + Check, + X, + CheckSquare, + Square, +} from "lucide-react"; + +// ==================== ํƒ€์ž… ==================== + +interface FilterPopupProps { + field: PivotFieldConfig; + data: any[]; + onFilterChange: (field: PivotFieldConfig, values: any[], type: "include" | "exclude") => void; + trigger?: React.ReactNode; +} + +// ==================== ๋ฉ”์ธ ์ปดํฌ๋„ŒํŠธ ==================== + +export const FilterPopup: React.FC = ({ + field, + data, + onFilterChange, + trigger, +}) => { + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedValues, setSelectedValues] = useState>( + new Set(field.filterValues || []) + ); + const [filterType, setFilterType] = useState<"include" | "exclude">( + field.filterType || "include" + ); + + // ๊ณ ์œ  ๊ฐ’ ์ถ”์ถœ + const uniqueValues = useMemo(() => { + const values = new Set(); + data.forEach((row) => { + const value = row[field.field]; + if (value !== null && value !== undefined) { + values.add(value); + } + }); + return Array.from(values).sort((a, b) => { + if (typeof a === "number" && typeof b === "number") return a - b; + return String(a).localeCompare(String(b), "ko"); + }); + }, [data, field.field]); + + // ํ•„ํ„ฐ๋ง๋œ ๊ฐ’ ๋ชฉ๋ก + const filteredValues = useMemo(() => { + if (!searchQuery) return uniqueValues; + const query = searchQuery.toLowerCase(); + return uniqueValues.filter((val) => + String(val).toLowerCase().includes(query) + ); + }, [uniqueValues, searchQuery]); + + // ๊ฐ’ ํ† ๊ธ€ + const handleValueToggle = (value: any) => { + const newSelected = new Set(selectedValues); + if (newSelected.has(value)) { + newSelected.delete(value); + } else { + newSelected.add(value); + } + setSelectedValues(newSelected); + }; + + // ๋ชจ๋‘ ์„ ํƒ + const handleSelectAll = () => { + setSelectedValues(new Set(filteredValues)); + }; + + // ๋ชจ๋‘ ํ•ด์ œ + const handleClearAll = () => { + setSelectedValues(new Set()); + }; + + // ์ ์šฉ + const handleApply = () => { + onFilterChange(field, Array.from(selectedValues), filterType); + setOpen(false); + }; + + // ์ดˆ๊ธฐํ™” + const handleReset = () => { + setSelectedValues(new Set()); + setFilterType("include"); + onFilterChange(field, [], "include"); + setOpen(false); + }; + + // ํ•„ํ„ฐ ํ™œ์„ฑ ์ƒํƒœ + const isFilterActive = field.filterValues && field.filterValues.length > 0; + + // ์„ ํƒ๋œ ํ•ญ๋ชฉ ์ˆ˜ + const selectedCount = selectedValues.size; + const totalCount = uniqueValues.length; + + return ( + + + {trigger || ( + + )} + + +
+
+ {field.caption} ํ•„ํ„ฐ +
+ + +
+
+ + {/* ๊ฒ€์ƒ‰ */} +
+ + setSearchQuery(e.target.value)} + className="pl-8 h-8 text-sm" + /> +
+ + {/* ์ „์ฒด ์„ ํƒ/ํ•ด์ œ */} +
+ + {selectedCount} / {totalCount} ์„ ํƒ๋จ + +
+ + +
+
+
+ + {/* ๊ฐ’ ๋ชฉ๋ก */} + +
+ {filteredValues.length === 0 ? ( +
+ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค +
+ ) : ( + filteredValues.map((value) => ( + + )) + )} +
+
+ + {/* ๋ฒ„ํŠผ */} +
+ +
+ + +
+
+
+
+ ); +}; + +export default FilterPopup; + diff --git a/frontend/lib/registry/components/pivot-grid/components/PivotChart.tsx b/frontend/lib/registry/components/pivot-grid/components/PivotChart.tsx new file mode 100644 index 00000000..6f7c3708 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/PivotChart.tsx @@ -0,0 +1,386 @@ +"use client"; + +/** + * PivotChart ์ปดํฌ๋„ŒํŠธ + * ํ”ผ๋ฒ— ๋ฐ์ดํ„ฐ๋ฅผ ์ฐจํŠธ๋กœ ์‹œ๊ฐํ™” + */ + +import React, { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { PivotResult, PivotChartConfig, PivotFieldConfig } from "../types"; +import { pathToKey } from "../utils/pivotEngine"; +import { + BarChart, + Bar, + LineChart, + Line, + AreaChart, + Area, + PieChart, + Pie, + Cell, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from "recharts"; + +// ==================== ํƒ€์ž… ==================== + +interface PivotChartProps { + pivotResult: PivotResult; + config: PivotChartConfig; + dataFields: PivotFieldConfig[]; + className?: string; +} + +// ==================== ์ƒ‰์ƒ ==================== + +const COLORS = [ + "#4472C4", // ํŒŒ๋ž‘ + "#ED7D31", // ์ฃผํ™ฉ + "#A5A5A5", // ํšŒ์ƒ‰ + "#FFC000", // ๋…ธ๋ž‘ + "#5B9BD5", // ํ•˜๋Š˜ + "#70AD47", // ์ดˆ๋ก + "#264478", // ์ง„ํ•œ ํŒŒ๋ž‘ + "#9E480E", // ์ง„ํ•œ ์ฃผํ™ฉ + "#636363", // ์ง„ํ•œ ํšŒ์ƒ‰ + "#997300", // ์ง„ํ•œ ๋…ธ๋ž‘ +]; + +// ==================== ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ ==================== + +function transformDataForChart( + pivotResult: PivotResult, + dataFields: PivotFieldConfig[] +): any[] { + const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; + + // ํ–‰ ๊ธฐ์ค€ ์ฐจํŠธ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + return flatRows.map((row) => { + const dataPoint: any = { + name: row.caption, + path: row.path, + }; + + // ๊ฐ ์—ด์— ๋Œ€ํ•œ ๋ฐ์ดํ„ฐ ์ถ”๊ฐ€ + flatColumns.forEach((col) => { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey); + + if (values && values.length > 0) { + const columnName = col.caption || "์ „์ฒด"; + dataPoint[columnName] = values[0].value; + } + }); + + // ์ด๊ณ„ ์ถ”๊ฐ€ + const rowTotal = grandTotals.row.get(pathToKey(row.path)); + if (rowTotal && rowTotal.length > 0) { + dataPoint["์ด๊ณ„"] = rowTotal[0].value; + } + + return dataPoint; + }); +} + +function transformDataForPie( + pivotResult: PivotResult, + dataFields: PivotFieldConfig[] +): any[] { + const { flatRows, grandTotals } = pivotResult; + + return flatRows.map((row, idx) => { + const rowTotal = grandTotals.row.get(pathToKey(row.path)); + return { + name: row.caption, + value: rowTotal?.[0]?.value || 0, + color: COLORS[idx % COLORS.length], + }; + }); +} + +// ==================== ์ฐจํŠธ ์ปดํฌ๋„ŒํŠธ ==================== + +const CustomTooltip: React.FC = ({ active, payload, label }) => { + if (!active || !payload || !payload.length) return null; + + return ( +
+

{label}

+ {payload.map((entry: any, idx: number) => ( +

+ {entry.name}: {entry.value?.toLocaleString()} +

+ ))} +
+ ); +}; + +// ๋ง‰๋Œ€ ์ฐจํŠธ +const PivotBarChart: React.FC<{ + data: any[]; + columns: string[]; + height: number; + showLegend: boolean; + stacked?: boolean; +}> = ({ data, columns, height, showLegend, stacked }) => { + return ( + + + + + value.toLocaleString()} + /> + } /> + {showLegend && ( + + )} + {columns.map((col, idx) => ( + + ))} + + + ); +}; + +// ์„  ์ฐจํŠธ +const PivotLineChart: React.FC<{ + data: any[]; + columns: string[]; + height: number; + showLegend: boolean; +}> = ({ data, columns, height, showLegend }) => { + return ( + + + + + value.toLocaleString()} + /> + } /> + {showLegend && ( + + )} + {columns.map((col, idx) => ( + + ))} + + + ); +}; + +// ์˜์—ญ ์ฐจํŠธ +const PivotAreaChart: React.FC<{ + data: any[]; + columns: string[]; + height: number; + showLegend: boolean; +}> = ({ data, columns, height, showLegend }) => { + return ( + + + + + value.toLocaleString()} + /> + } /> + {showLegend && ( + + )} + {columns.map((col, idx) => ( + + ))} + + + ); +}; + +// ํŒŒ์ด ์ฐจํŠธ +const PivotPieChart: React.FC<{ + data: any[]; + height: number; + showLegend: boolean; +}> = ({ data, height, showLegend }) => { + return ( + + + + `${name} (${(percent * 100).toFixed(1)}%)` + } + labelLine + > + {data.map((entry, idx) => ( + + ))} + + } /> + {showLegend && ( + + )} + + + ); +}; + +// ==================== ๋ฉ”์ธ ์ปดํฌ๋„ŒํŠธ ==================== + +export const PivotChart: React.FC = ({ + pivotResult, + config, + dataFields, + className, +}) => { + // ์ฐจํŠธ ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ + const chartData = useMemo(() => { + if (config.type === "pie") { + return transformDataForPie(pivotResult, dataFields); + } + return transformDataForChart(pivotResult, dataFields); + }, [pivotResult, dataFields, config.type]); + + // ์—ด ์ด๋ฆ„ ๋ชฉ๋ก (ํŒŒ์ด ์ฐจํŠธ ์ œ์™ธ) + const columns = useMemo(() => { + if (config.type === "pie" || chartData.length === 0) return []; + + const firstItem = chartData[0]; + return Object.keys(firstItem).filter( + (key) => key !== "name" && key !== "path" + ); + }, [chartData, config.type]); + + const height = config.height || 300; + const showLegend = config.showLegend !== false; + + if (!config.enabled) { + return null; + } + + return ( +
+ {/* ์ฐจํŠธ ๋ Œ๋”๋ง */} + {config.type === "bar" && ( + + )} + + {config.type === "stackedBar" && ( + + )} + + {config.type === "line" && ( + + )} + + {config.type === "area" && ( + + )} + + {config.type === "pie" && ( + + )} +
+ ); +}; + +export default PivotChart; + diff --git a/frontend/lib/registry/components/pivot-grid/components/index.ts b/frontend/lib/registry/components/pivot-grid/components/index.ts new file mode 100644 index 00000000..a901a7cf --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/index.ts @@ -0,0 +1,10 @@ +/** + * PivotGrid ์„œ๋ธŒ ์ปดํฌ๋„ŒํŠธ ๋‚ด๋ณด๋‚ด๊ธฐ + */ + +export { FieldPanel } from "./FieldPanel"; +export { FieldChooser } from "./FieldChooser"; +export { DrillDownModal } from "./DrillDownModal"; +export { FilterPopup } from "./FilterPopup"; +export { PivotChart } from "./PivotChart"; + diff --git a/frontend/lib/registry/components/pivot-grid/hooks/index.ts b/frontend/lib/registry/components/pivot-grid/hooks/index.ts new file mode 100644 index 00000000..a9a1a4eb --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/hooks/index.ts @@ -0,0 +1,27 @@ +/** + * PivotGrid ์ปค์Šคํ…€ ํ›… ๋‚ด๋ณด๋‚ด๊ธฐ + */ + +export { + useVirtualScroll, + useVirtualColumnScroll, + useVirtual2DScroll, +} from "./useVirtualScroll"; + +export type { + VirtualScrollOptions, + VirtualScrollResult, + VirtualColumnScrollOptions, + VirtualColumnScrollResult, + Virtual2DScrollOptions, + Virtual2DScrollResult, +} from "./useVirtualScroll"; + +export { usePivotState } from "./usePivotState"; + +export type { + PivotStateConfig, + SavedPivotState, + UsePivotStateResult, +} from "./usePivotState"; + diff --git a/frontend/lib/registry/components/pivot-grid/hooks/usePivotState.ts b/frontend/lib/registry/components/pivot-grid/hooks/usePivotState.ts new file mode 100644 index 00000000..9b001377 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/hooks/usePivotState.ts @@ -0,0 +1,231 @@ +"use client"; + +/** + * PivotState ํ›… + * ํ”ผ๋ฒ— ๊ทธ๋ฆฌ๋“œ ์ƒํƒœ ์ €์žฅ/๋ณต์› ๊ด€๋ฆฌ + */ + +import { useState, useEffect, useCallback } from "react"; +import { PivotFieldConfig, PivotGridState } from "../types"; + +// ==================== ํƒ€์ž… ==================== + +export interface PivotStateConfig { + enabled: boolean; + storageKey?: string; + storageType?: "localStorage" | "sessionStorage"; +} + +export interface SavedPivotState { + version: string; + timestamp: number; + fields: PivotFieldConfig[]; + expandedRowPaths: string[][]; + expandedColumnPaths: string[][]; + filterConfig: Record; + sortConfig: { + field: string; + direction: "asc" | "desc"; + } | null; +} + +export interface UsePivotStateResult { + // ์ƒํƒœ + fields: PivotFieldConfig[]; + pivotState: PivotGridState; + + // ์ƒํƒœ ๋ณ€๊ฒฝ + setFields: (fields: PivotFieldConfig[]) => void; + setPivotState: (state: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => void; + + // ์ €์žฅ/๋ณต์› + saveState: () => void; + loadState: () => boolean; + clearState: () => void; + hasStoredState: () => boolean; + + // ์ƒํƒœ ์ •๋ณด + lastSaved: Date | null; + isDirty: boolean; +} + +// ==================== ์ƒ์ˆ˜ ==================== + +const STATE_VERSION = "1.0.0"; +const DEFAULT_STORAGE_KEY = "pivot-grid-state"; + +// ==================== ํ›… ==================== + +export function usePivotState( + initialFields: PivotFieldConfig[], + config: PivotStateConfig +): UsePivotStateResult { + const { + enabled, + storageKey = DEFAULT_STORAGE_KEY, + storageType = "localStorage", + } = config; + + // ์ƒํƒœ + const [fields, setFieldsInternal] = useState(initialFields); + const [pivotState, setPivotStateInternal] = useState({ + expandedRowPaths: [], + expandedColumnPaths: [], + sortConfig: null, + filterConfig: {}, + }); + const [lastSaved, setLastSaved] = useState(null); + const [isDirty, setIsDirty] = useState(false); + const [initialStateLoaded, setInitialStateLoaded] = useState(false); + + // ์Šคํ† ๋ฆฌ์ง€ ๊ฐ€์ ธ์˜ค๊ธฐ + const getStorage = useCallback(() => { + if (typeof window === "undefined") return null; + return storageType === "localStorage" ? localStorage : sessionStorage; + }, [storageType]); + + // ์ €์žฅ๋œ ์ƒํƒœ ํ™•์ธ + const hasStoredState = useCallback((): boolean => { + const storage = getStorage(); + if (!storage) return false; + return storage.getItem(storageKey) !== null; + }, [getStorage, storageKey]); + + // ์ƒํƒœ ์ €์žฅ + const saveState = useCallback(() => { + if (!enabled) return; + + const storage = getStorage(); + if (!storage) return; + + const stateToSave: SavedPivotState = { + version: STATE_VERSION, + timestamp: Date.now(), + fields, + expandedRowPaths: pivotState.expandedRowPaths, + expandedColumnPaths: pivotState.expandedColumnPaths, + filterConfig: pivotState.filterConfig, + sortConfig: pivotState.sortConfig, + }; + + try { + storage.setItem(storageKey, JSON.stringify(stateToSave)); + setLastSaved(new Date()); + setIsDirty(false); + console.log("โœ… ํ”ผ๋ฒ— ์ƒํƒœ ์ €์žฅ๋จ:", storageKey); + } catch (error) { + console.error("โŒ ํ”ผ๋ฒ— ์ƒํƒœ ์ €์žฅ ์‹คํŒจ:", error); + } + }, [enabled, getStorage, storageKey, fields, pivotState]); + + // ์ƒํƒœ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ + const loadState = useCallback((): boolean => { + if (!enabled) return false; + + const storage = getStorage(); + if (!storage) return false; + + try { + const saved = storage.getItem(storageKey); + if (!saved) return false; + + const parsedState: SavedPivotState = JSON.parse(saved); + + // ๋ฒ„์ „ ์ฒดํฌ + if (parsedState.version !== STATE_VERSION) { + console.warn("โš ๏ธ ์ €์žฅ๋œ ์ƒํƒœ ๋ฒ„์ „์ด ๋‹ค๋ฆ„, ๋ฌด์‹œ๋จ"); + return false; + } + + // ์ƒํƒœ ๋ณต์› + setFieldsInternal(parsedState.fields); + setPivotStateInternal({ + expandedRowPaths: parsedState.expandedRowPaths, + expandedColumnPaths: parsedState.expandedColumnPaths, + sortConfig: parsedState.sortConfig, + filterConfig: parsedState.filterConfig, + }); + setLastSaved(new Date(parsedState.timestamp)); + setIsDirty(false); + + console.log("โœ… ํ”ผ๋ฒ— ์ƒํƒœ ๋ณต์›๋จ:", storageKey); + return true; + } catch (error) { + console.error("โŒ ํ”ผ๋ฒ— ์ƒํƒœ ๋ณต์› ์‹คํŒจ:", error); + return false; + } + }, [enabled, getStorage, storageKey]); + + // ์ƒํƒœ ์ดˆ๊ธฐํ™” + const clearState = useCallback(() => { + const storage = getStorage(); + if (!storage) return; + + try { + storage.removeItem(storageKey); + setLastSaved(null); + console.log("๐Ÿ—‘๏ธ ํ”ผ๋ฒ— ์ƒํƒœ ์‚ญ์ œ๋จ:", storageKey); + } catch (error) { + console.error("โŒ ํ”ผ๋ฒ— ์ƒํƒœ ์‚ญ์ œ ์‹คํŒจ:", error); + } + }, [getStorage, storageKey]); + + // ํ•„๋“œ ๋ณ€๊ฒฝ (dirty ํ”Œ๋ž˜๊ทธ ์„ค์ •) + const setFields = useCallback((newFields: PivotFieldConfig[]) => { + setFieldsInternal(newFields); + setIsDirty(true); + }, []); + + // ํ”ผ๋ฒ— ์ƒํƒœ ๋ณ€๊ฒฝ (dirty ํ”Œ๋ž˜๊ทธ ์„ค์ •) + const setPivotState = useCallback( + (newState: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => { + setPivotStateInternal(newState); + setIsDirty(true); + }, + [] + ); + + // ์ดˆ๊ธฐ ๋กœ๋“œ + useEffect(() => { + if (!initialStateLoaded && enabled && hasStoredState()) { + loadState(); + setInitialStateLoaded(true); + } + }, [enabled, hasStoredState, loadState, initialStateLoaded]); + + // ์ดˆ๊ธฐ ํ•„๋“œ ๋™๊ธฐํ™” (์ €์žฅ๋œ ์ƒํƒœ๊ฐ€ ์—†์„ ๋•Œ) + useEffect(() => { + if (initialStateLoaded) return; + if (!hasStoredState() && initialFields.length > 0) { + setFieldsInternal(initialFields); + setInitialStateLoaded(true); + } + }, [initialFields, hasStoredState, initialStateLoaded]); + + // ์ž๋™ ์ €์žฅ (๋ณ€๊ฒฝ ์‹œ) + useEffect(() => { + if (!enabled || !isDirty) return; + + const timeout = setTimeout(() => { + saveState(); + }, 1000); // 1์ดˆ ๋””๋ฐ”์šด์Šค + + return () => clearTimeout(timeout); + }, [enabled, isDirty, saveState]); + + return { + fields, + pivotState, + setFields, + setPivotState, + saveState, + loadState, + clearState, + hasStoredState, + lastSaved, + isDirty, + }; +} + +export default usePivotState; + diff --git a/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts b/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts new file mode 100644 index 00000000..152cb2df --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts @@ -0,0 +1,312 @@ +"use client"; + +/** + * Virtual Scroll ํ›… + * ๋Œ€์šฉ๋Ÿ‰ ํ”ผ๋ฒ— ๋ฐ์ดํ„ฐ์˜ ๊ฐ€์ƒ ์Šคํฌ๋กค ์ฒ˜๋ฆฌ + */ + +import { useState, useEffect, useRef, useMemo, useCallback } from "react"; + +// ==================== ํƒ€์ž… ==================== + +export interface VirtualScrollOptions { + itemCount: number; // ์ „์ฒด ์•„์ดํ…œ ์ˆ˜ + itemHeight: number; // ๊ฐ ์•„์ดํ…œ ๋†’์ด (px) + containerHeight: number; // ์ปจํ…Œ์ด๋„ˆ ๋†’์ด (px) + overscan?: number; // ๋ฒ„ํผ ์•„์ดํ…œ ์ˆ˜ (๊ธฐ๋ณธ: 5) +} + +export interface VirtualScrollResult { + // ํ˜„์žฌ ๋ณด์—ฌ์•ผ ํ•  ์•„์ดํ…œ ๋ฒ”์œ„ + startIndex: number; + endIndex: number; + + // ๊ฐ€์ƒ ์Šคํฌ๋กค ๊ด€๋ จ ๊ฐ’ + totalHeight: number; // ์ „์ฒด ๋†’์ด + offsetTop: number; // ์ƒ๋‹จ ์˜คํ”„์…‹ + + // ๋ณด์—ฌ์ง€๋Š” ์•„์ดํ…œ ๋ชฉ๋ก + visibleItems: number[]; + + // ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ + onScroll: (scrollTop: number) => void; + + // ์ปจํ…Œ์ด๋„ˆ ref + containerRef: React.RefObject; +} + +// ==================== ํ›… ==================== + +export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollResult { + const { + itemCount, + itemHeight, + containerHeight, + overscan = 5, + } = options; + + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + + // ๋ณด์ด๋Š” ์•„์ดํ…œ ์ˆ˜ + const visibleCount = Math.ceil(containerHeight / itemHeight); + + // ์‹œ์ž‘/๋ ์ธ๋ฑ์Šค ๊ณ„์‚ฐ + const { startIndex, endIndex } = useMemo(() => { + const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); + const end = Math.min( + itemCount - 1, + Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan + ); + return { startIndex: start, endIndex: end }; + }, [scrollTop, itemHeight, containerHeight, itemCount, overscan]); + + // ์ „์ฒด ๋†’์ด + const totalHeight = itemCount * itemHeight; + + // ์ƒ๋‹จ ์˜คํ”„์…‹ + const offsetTop = startIndex * itemHeight; + + // ๋ณด์ด๋Š” ์•„์ดํ…œ ์ธ๋ฑ์Šค ๋ฐฐ์—ด + const visibleItems = useMemo(() => { + const items: number[] = []; + for (let i = startIndex; i <= endIndex; i++) { + items.push(i); + } + return items; + }, [startIndex, endIndex]); + + // ์Šคํฌ๋กค ํ•ธ๋“ค๋Ÿฌ + const onScroll = useCallback((newScrollTop: number) => { + setScrollTop(newScrollTop); + }, []); + + // ์Šคํฌ๋กค ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleScroll = () => { + setScrollTop(container.scrollTop); + }; + + container.addEventListener("scroll", handleScroll, { passive: true }); + + return () => { + container.removeEventListener("scroll", handleScroll); + }; + }, []); + + return { + startIndex, + endIndex, + totalHeight, + offsetTop, + visibleItems, + onScroll, + containerRef, + }; +} + +// ==================== ์—ด ๊ฐ€์ƒ ์Šคํฌ๋กค ==================== + +export interface VirtualColumnScrollOptions { + columnCount: number; // ์ „์ฒด ์—ด ์ˆ˜ + columnWidth: number; // ๊ฐ ์—ด ๋„ˆ๋น„ (px) + containerWidth: number; // ์ปจํ…Œ์ด๋„ˆ ๋„ˆ๋น„ (px) + overscan?: number; +} + +export interface VirtualColumnScrollResult { + startIndex: number; + endIndex: number; + totalWidth: number; + offsetLeft: number; + visibleColumns: number[]; + onScroll: (scrollLeft: number) => void; +} + +export function useVirtualColumnScroll( + options: VirtualColumnScrollOptions +): VirtualColumnScrollResult { + const { + columnCount, + columnWidth, + containerWidth, + overscan = 3, + } = options; + + const [scrollLeft, setScrollLeft] = useState(0); + + const { startIndex, endIndex } = useMemo(() => { + const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - overscan); + const end = Math.min( + columnCount - 1, + Math.ceil((scrollLeft + containerWidth) / columnWidth) + overscan + ); + return { startIndex: start, endIndex: end }; + }, [scrollLeft, columnWidth, containerWidth, columnCount, overscan]); + + const totalWidth = columnCount * columnWidth; + const offsetLeft = startIndex * columnWidth; + + const visibleColumns = useMemo(() => { + const cols: number[] = []; + for (let i = startIndex; i <= endIndex; i++) { + cols.push(i); + } + return cols; + }, [startIndex, endIndex]); + + const onScroll = useCallback((newScrollLeft: number) => { + setScrollLeft(newScrollLeft); + }, []); + + return { + startIndex, + endIndex, + totalWidth, + offsetLeft, + visibleColumns, + onScroll, + }; +} + +// ==================== 2D ๊ฐ€์ƒ ์Šคํฌ๋กค (ํ–‰ + ์—ด) ==================== + +export interface Virtual2DScrollOptions { + rowCount: number; + columnCount: number; + rowHeight: number; + columnWidth: number; + containerHeight: number; + containerWidth: number; + rowOverscan?: number; + columnOverscan?: number; +} + +export interface Virtual2DScrollResult { + // ํ–‰ ๋ฒ”์œ„ + rowStartIndex: number; + rowEndIndex: number; + totalHeight: number; + offsetTop: number; + visibleRows: number[]; + + // ์—ด ๋ฒ”์œ„ + columnStartIndex: number; + columnEndIndex: number; + totalWidth: number; + offsetLeft: number; + visibleColumns: number[]; + + // ์Šคํฌ๋กค ํ•ธ๋“ค๋Ÿฌ + onScroll: (scrollTop: number, scrollLeft: number) => void; + + // ์ปจํ…Œ์ด๋„ˆ ref + containerRef: React.RefObject; +} + +export function useVirtual2DScroll( + options: Virtual2DScrollOptions +): Virtual2DScrollResult { + const { + rowCount, + columnCount, + rowHeight, + columnWidth, + containerHeight, + containerWidth, + rowOverscan = 5, + columnOverscan = 3, + } = options; + + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [scrollLeft, setScrollLeft] = useState(0); + + // ํ–‰ ๊ณ„์‚ฐ + const { rowStartIndex, rowEndIndex, visibleRows } = useMemo(() => { + const start = Math.max(0, Math.floor(scrollTop / rowHeight) - rowOverscan); + const end = Math.min( + rowCount - 1, + Math.ceil((scrollTop + containerHeight) / rowHeight) + rowOverscan + ); + + const rows: number[] = []; + for (let i = start; i <= end; i++) { + rows.push(i); + } + + return { + rowStartIndex: start, + rowEndIndex: end, + visibleRows: rows, + }; + }, [scrollTop, rowHeight, containerHeight, rowCount, rowOverscan]); + + // ์—ด ๊ณ„์‚ฐ + const { columnStartIndex, columnEndIndex, visibleColumns } = useMemo(() => { + const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - columnOverscan); + const end = Math.min( + columnCount - 1, + Math.ceil((scrollLeft + containerWidth) / columnWidth) + columnOverscan + ); + + const cols: number[] = []; + for (let i = start; i <= end; i++) { + cols.push(i); + } + + return { + columnStartIndex: start, + columnEndIndex: end, + visibleColumns: cols, + }; + }, [scrollLeft, columnWidth, containerWidth, columnCount, columnOverscan]); + + const totalHeight = rowCount * rowHeight; + const totalWidth = columnCount * columnWidth; + const offsetTop = rowStartIndex * rowHeight; + const offsetLeft = columnStartIndex * columnWidth; + + const onScroll = useCallback((newScrollTop: number, newScrollLeft: number) => { + setScrollTop(newScrollTop); + setScrollLeft(newScrollLeft); + }, []); + + // ์Šคํฌ๋กค ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleScroll = () => { + setScrollTop(container.scrollTop); + setScrollLeft(container.scrollLeft); + }; + + container.addEventListener("scroll", handleScroll, { passive: true }); + + return () => { + container.removeEventListener("scroll", handleScroll); + }; + }, []); + + return { + rowStartIndex, + rowEndIndex, + totalHeight, + offsetTop, + visibleRows, + columnStartIndex, + columnEndIndex, + totalWidth, + offsetLeft, + visibleColumns, + onScroll, + containerRef, + }; +} + +export default useVirtualScroll; + diff --git a/frontend/lib/registry/components/pivot-grid/index.ts b/frontend/lib/registry/components/pivot-grid/index.ts index 16044dbc..b1bbe99b 100644 --- a/frontend/lib/registry/components/pivot-grid/index.ts +++ b/frontend/lib/registry/components/pivot-grid/index.ts @@ -8,6 +8,7 @@ export type { // ๊ธฐ๋ณธ ํƒ€์ž… PivotAreaType, AggregationType, + SummaryDisplayMode, SortDirection, DateGroupInterval, FieldDataType, diff --git a/frontend/lib/registry/components/pivot-grid/types.ts b/frontend/lib/registry/components/pivot-grid/types.ts index e0ea3199..e711a255 100644 --- a/frontend/lib/registry/components/pivot-grid/types.ts +++ b/frontend/lib/registry/components/pivot-grid/types.ts @@ -11,6 +11,19 @@ export type PivotAreaType = "row" | "column" | "data" | "filter"; // ์ง‘๊ณ„ ํ•จ์ˆ˜ ํƒ€์ž… export type AggregationType = "sum" | "count" | "avg" | "min" | "max" | "countDistinct"; +// ์š”์•ฝ ํ‘œ์‹œ ๋ชจ๋“œ +export type SummaryDisplayMode = + | "absoluteValue" // ์ ˆ๋Œ€๊ฐ’ (๊ธฐ๋ณธ) + | "percentOfColumnTotal" // ์—ด ์ด๊ณ„ ๋Œ€๋น„ % + | "percentOfRowTotal" // ํ–‰ ์ด๊ณ„ ๋Œ€๋น„ % + | "percentOfGrandTotal" // ์ „์ฒด ์ด๊ณ„ ๋Œ€๋น„ % + | "percentOfColumnGrandTotal" // ์—ด ๋Œ€์ด๊ณ„ ๋Œ€๋น„ % + | "percentOfRowGrandTotal" // ํ–‰ ๋Œ€์ด๊ณ„ ๋Œ€๋น„ % + | "runningTotalByRow" // ํ–‰ ๋ฐฉํ–ฅ ๋ˆ„๊ณ„ + | "runningTotalByColumn" // ์—ด ๋ฐฉํ–ฅ ๋ˆ„๊ณ„ + | "differenceFromPrevious" // ์ด์ „ ๋Œ€๋น„ ์ฐจ์ด + | "percentDifferenceFromPrevious"; // ์ด์ „ ๋Œ€๋น„ % ์ฐจ์ด + // ์ •๋ ฌ ๋ฐฉํ–ฅ export type SortDirection = "asc" | "desc" | "none"; @@ -48,6 +61,8 @@ export interface PivotFieldConfig { // ์ง‘๊ณ„ ์„ค์ • (data ์˜์—ญ์šฉ) summaryType?: AggregationType; // ์ง‘๊ณ„ ํ•จ์ˆ˜ + summaryDisplayMode?: SummaryDisplayMode; // ์š”์•ฝ ํ‘œ์‹œ ๋ชจ๋“œ + showValuesAs?: SummaryDisplayMode; // ๊ฐ’ ํ‘œ์‹œ ๋ฐฉ์‹ (summaryDisplayMode ๋ณ„์นญ) // ์ •๋ ฌ ์„ค์ • sortBy?: "value" | "caption"; // ์ •๋ ฌ ๊ธฐ์ค€ @@ -151,6 +166,45 @@ export interface PivotChartConfig { animate?: boolean; } +// ์กฐ๊ฑด๋ถ€ ์„œ์‹ ๊ทœ์น™ +export interface ConditionalFormatRule { + id: string; + type: "colorScale" | "dataBar" | "iconSet" | "cellValue"; + field?: string; // ์ ์šฉํ•  ๋ฐ์ดํ„ฐ ํ•„๋“œ (์—†์œผ๋ฉด ์ „์ฒด) + + // colorScale: ๊ฐ’ ๋ฒ”์œ„์— ๋”ฐ๋ฅธ ์ƒ‰์ƒ ๊ทธ๋ผ๋ฐ์ด์…˜ + colorScale?: { + minColor: string; // ์ตœ์†Œ๊ฐ’ ์ƒ‰์ƒ (์˜ˆ: "#ff0000") + midColor?: string; // ์ค‘๊ฐ„๊ฐ’ ์ƒ‰์ƒ (์„ ํƒ) + maxColor: string; // ์ตœ๋Œ€๊ฐ’ ์ƒ‰์ƒ (์˜ˆ: "#00ff00") + }; + + // dataBar: ๊ฐ’์— ๋”ฐ๋ฅธ ๋ง‰๋Œ€ ํ‘œ์‹œ + dataBar?: { + color: string; // ๋ง‰๋Œ€ ์ƒ‰์ƒ + showValue?: boolean; // ๊ฐ’ ํ‘œ์‹œ ์—ฌ๋ถ€ + minValue?: number; // ์ตœ์†Œ๊ฐ’ (์—†์œผ๋ฉด ์ž๋™) + maxValue?: number; // ์ตœ๋Œ€๊ฐ’ (์—†์œผ๋ฉด ์ž๋™) + }; + + // iconSet: ๊ฐ’์— ๋”ฐ๋ฅธ ์•„์ด์ฝ˜ ํ‘œ์‹œ + iconSet?: { + type: "arrows" | "traffic" | "rating" | "flags"; + thresholds: number[]; // ๊ฒฝ๊ณ„๊ฐ’ (์˜ˆ: [30, 70] = 0-30, 30-70, 70-100) + reverse?: boolean; // ์•„์ด์ฝ˜ ์ˆœ์„œ ๋ฐ˜์ „ + }; + + // cellValue: ์กฐ๊ฑด์— ๋”ฐ๋ฅธ ์Šคํƒ€์ผ + cellValue?: { + operator: ">" | ">=" | "<" | "<=" | "=" | "!=" | "between"; + value1: number; + value2?: number; // between ์—ฐ์‚ฐ์ž์šฉ + backgroundColor?: string; + textColor?: string; + bold?: boolean; + }; +} + // ์Šคํƒ€์ผ ์„ค์ • export interface PivotStyleConfig { theme: "default" | "compact" | "modern"; @@ -159,6 +213,7 @@ export interface PivotStyleConfig { borderStyle: "none" | "light" | "heavy"; alternateRowColors?: boolean; highlightTotals?: boolean; // ์ดํ•ฉ๊ณ„ ๊ฐ•์กฐ + conditionalFormats?: ConditionalFormatRule[]; // ์กฐ๊ฑด๋ถ€ ์„œ์‹ ๊ทœ์น™ } // ==================== ๋‚ด๋ณด๋‚ด๊ธฐ ์„ค์ • ==================== diff --git a/frontend/lib/registry/components/pivot-grid/utils/conditionalFormat.ts b/frontend/lib/registry/components/pivot-grid/utils/conditionalFormat.ts new file mode 100644 index 00000000..a9195d92 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/utils/conditionalFormat.ts @@ -0,0 +1,311 @@ +/** + * ์กฐ๊ฑด๋ถ€ ์„œ์‹ ์œ ํ‹ธ๋ฆฌํ‹ฐ + * ์…€ ๊ฐ’์— ๋”ฐ๋ฅธ ์Šคํƒ€์ผ ๊ณ„์‚ฐ + */ + +import { ConditionalFormatRule } from "../types"; + +// ==================== ํƒ€์ž… ==================== + +export interface CellFormatStyle { + backgroundColor?: string; + textColor?: string; + fontWeight?: string; + dataBarWidth?: number; // 0-100% + dataBarColor?: string; + icon?: string; // ์ด๋ชจ์ง€ ๋˜๋Š” ์•„์ด์ฝ˜ ์ด๋ฆ„ +} + +// ==================== ์ƒ‰์ƒ ์œ ํ‹ธ๋ฆฌํ‹ฐ ==================== + +/** + * HEX ์ƒ‰์ƒ์„ RGB๋กœ ๋ณ€ํ™˜ + */ +function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : null; +} + +/** + * RGB๋ฅผ HEX๋กœ ๋ณ€ํ™˜ + */ +function rgbToHex(r: number, g: number, b: number): string { + return ( + "#" + + [r, g, b] + .map((x) => { + const hex = Math.round(x).toString(16); + return hex.length === 1 ? "0" + hex : hex; + }) + .join("") + ); +} + +/** + * ๋‘ ์ƒ‰์ƒ ์‚ฌ์ด์˜ ๋ณด๊ฐ„ + */ +function interpolateColor( + color1: string, + color2: string, + factor: number +): string { + const rgb1 = hexToRgb(color1); + const rgb2 = hexToRgb(color2); + + if (!rgb1 || !rgb2) return color1; + + const r = rgb1.r + (rgb2.r - rgb1.r) * factor; + const g = rgb1.g + (rgb2.g - rgb1.g) * factor; + const b = rgb1.b + (rgb2.b - rgb1.b) * factor; + + return rgbToHex(r, g, b); +} + +// ==================== ์กฐ๊ฑด๋ถ€ ์„œ์‹ ๊ณ„์‚ฐ ==================== + +/** + * Color Scale ์Šคํƒ€์ผ ๊ณ„์‚ฐ + */ +function applyColorScale( + value: number, + minValue: number, + maxValue: number, + rule: ConditionalFormatRule +): CellFormatStyle { + if (!rule.colorScale) return {}; + + const { minColor, midColor, maxColor } = rule.colorScale; + const range = maxValue - minValue; + + if (range === 0) { + return { backgroundColor: minColor }; + } + + const normalizedValue = (value - minValue) / range; + + let backgroundColor: string; + + if (midColor) { + // 3์ƒ‰ ๊ทธ๋ผ๋ฐ์ด์…˜ + if (normalizedValue <= 0.5) { + backgroundColor = interpolateColor(minColor, midColor, normalizedValue * 2); + } else { + backgroundColor = interpolateColor(midColor, maxColor, (normalizedValue - 0.5) * 2); + } + } else { + // 2์ƒ‰ ๊ทธ๋ผ๋ฐ์ด์…˜ + backgroundColor = interpolateColor(minColor, maxColor, normalizedValue); + } + + // ๋ฐฐ๊ฒฝ์ƒ‰์— ๋”ฐ๋ฅธ ํ…์ŠคํŠธ ์ƒ‰์ƒ ๊ฒฐ์ • + const rgb = hexToRgb(backgroundColor); + const textColor = + rgb && rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114 > 186 + ? "#000000" + : "#ffffff"; + + return { backgroundColor, textColor }; +} + +/** + * Data Bar ์Šคํƒ€์ผ ๊ณ„์‚ฐ + */ +function applyDataBar( + value: number, + minValue: number, + maxValue: number, + rule: ConditionalFormatRule +): CellFormatStyle { + if (!rule.dataBar) return {}; + + const { color, minValue: ruleMin, maxValue: ruleMax } = rule.dataBar; + + const min = ruleMin ?? minValue; + const max = ruleMax ?? maxValue; + const range = max - min; + + if (range === 0) { + return { dataBarWidth: 100, dataBarColor: color }; + } + + const width = Math.max(0, Math.min(100, ((value - min) / range) * 100)); + + return { + dataBarWidth: width, + dataBarColor: color, + }; +} + +/** + * Icon Set ์Šคํƒ€์ผ ๊ณ„์‚ฐ + */ +function applyIconSet( + value: number, + minValue: number, + maxValue: number, + rule: ConditionalFormatRule +): CellFormatStyle { + if (!rule.iconSet) return {}; + + const { type, thresholds, reverse } = rule.iconSet; + const range = maxValue - minValue; + const percentage = range === 0 ? 100 : ((value - minValue) / range) * 100; + + // ์•„์ด์ฝ˜ ์ •์˜ + const iconSets: Record = { + arrows: ["โ†“", "โ†’", "โ†‘"], + traffic: ["๐Ÿ”ด", "๐ŸŸก", "๐ŸŸข"], + rating: ["โญ", "โญโญ", "โญโญโญ"], + flags: ["๐Ÿšฉ", "๐Ÿณ๏ธ", "๐Ÿ"], + }; + + const icons = iconSets[type] || iconSets.arrows; + const sortedIcons = reverse ? [...icons].reverse() : icons; + + // ์ž„๊ณ„๊ฐ’์— ๋”ฐ๋ฅธ ์•„์ด์ฝ˜ ์„ ํƒ + let iconIndex = 0; + for (let i = 0; i < thresholds.length; i++) { + if (percentage >= thresholds[i]) { + iconIndex = i + 1; + } + } + iconIndex = Math.min(iconIndex, sortedIcons.length - 1); + + return { + icon: sortedIcons[iconIndex], + }; +} + +/** + * Cell Value ์กฐ๊ฑด ์Šคํƒ€์ผ ๊ณ„์‚ฐ + */ +function applyCellValue( + value: number, + rule: ConditionalFormatRule +): CellFormatStyle { + if (!rule.cellValue) return {}; + + const { operator, value1, value2, backgroundColor, textColor, bold } = + rule.cellValue; + + let matches = false; + + switch (operator) { + case ">": + matches = value > value1; + break; + case ">=": + matches = value >= value1; + break; + case "<": + matches = value < value1; + break; + case "<=": + matches = value <= value1; + break; + case "=": + matches = value === value1; + break; + case "!=": + matches = value !== value1; + break; + case "between": + matches = value2 !== undefined && value >= value1 && value <= value2; + break; + } + + if (!matches) return {}; + + return { + backgroundColor, + textColor, + fontWeight: bold ? "bold" : undefined, + }; +} + +// ==================== ๋ฉ”์ธ ํ•จ์ˆ˜ ==================== + +/** + * ์กฐ๊ฑด๋ถ€ ์„œ์‹ ์ ์šฉ + */ +export function getConditionalStyle( + value: number | null | undefined, + field: string, + rules: ConditionalFormatRule[], + allValues: number[] // ํ•ด๋‹น ํ•„๋“œ์˜ ๋ชจ๋“  ๊ฐ’ (min/max ๊ณ„์‚ฐ์šฉ) +): CellFormatStyle { + if (value === null || value === undefined || isNaN(value)) { + return {}; + } + + if (!rules || rules.length === 0) { + return {}; + } + + // min/max ๊ณ„์‚ฐ + const numericValues = allValues.filter((v) => !isNaN(v)); + const minValue = Math.min(...numericValues); + const maxValue = Math.max(...numericValues); + + let resultStyle: CellFormatStyle = {}; + + // ํ•ด๋‹น ํ•„๋“œ์— ์ ์šฉ๋˜๋Š” ๊ทœ์น™ ํ•„ํ„ฐ๋ง ๋ฐ ์ ์šฉ + for (const rule of rules) { + // ํ•„๋“œ ํ•„ํ„ฐ ํ™•์ธ + if (rule.field && rule.field !== field) { + continue; + } + + let ruleStyle: CellFormatStyle = {}; + + switch (rule.type) { + case "colorScale": + ruleStyle = applyColorScale(value, minValue, maxValue, rule); + break; + case "dataBar": + ruleStyle = applyDataBar(value, minValue, maxValue, rule); + break; + case "iconSet": + ruleStyle = applyIconSet(value, minValue, maxValue, rule); + break; + case "cellValue": + ruleStyle = applyCellValue(value, rule); + break; + } + + // ์Šคํƒ€์ผ ๋ณ‘ํ•ฉ (๋‚˜์ค‘ ๊ทœ์น™์ด ์šฐ์„ ) + resultStyle = { ...resultStyle, ...ruleStyle }; + } + + return resultStyle; +} + +/** + * ์กฐ๊ฑด๋ถ€ ์„œ์‹ ์Šคํƒ€์ผ์„ React ์Šคํƒ€์ผ ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜ + */ +export function formatStyleToReact( + style: CellFormatStyle +): React.CSSProperties { + const result: React.CSSProperties = {}; + + if (style.backgroundColor) { + result.backgroundColor = style.backgroundColor; + } + if (style.textColor) { + result.color = style.textColor; + } + if (style.fontWeight) { + result.fontWeight = style.fontWeight as any; + } + + return result; +} + +export default getConditionalStyle; + diff --git a/frontend/lib/registry/components/pivot-grid/utils/exportExcel.ts b/frontend/lib/registry/components/pivot-grid/utils/exportExcel.ts new file mode 100644 index 00000000..6069a3a5 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/utils/exportExcel.ts @@ -0,0 +1,202 @@ +/** + * Excel ๋‚ด๋ณด๋‚ด๊ธฐ ์œ ํ‹ธ๋ฆฌํ‹ฐ + * ํ”ผ๋ฒ— ํ…Œ์ด๋ธ” ๋ฐ์ดํ„ฐ๋ฅผ Excel ํŒŒ์ผ๋กœ ๋‚ด๋ณด๋‚ด๊ธฐ + * xlsx ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์šฉ (๋ธŒ๋ผ์šฐ์ € ํ˜ธํ™˜) + */ + +import * as XLSX from "xlsx"; +import { + PivotResult, + PivotFieldConfig, + PivotTotalsConfig, +} from "../types"; +import { pathToKey } from "./pivotEngine"; + +// ==================== ํƒ€์ž… ==================== + +export interface ExportOptions { + fileName?: string; + sheetName?: string; + title?: string; + subtitle?: string; + includeHeaders?: boolean; + includeTotals?: boolean; +} + +// ==================== ๋ฉ”์ธ ํ•จ์ˆ˜ ==================== + +/** + * ํ”ผ๋ฒ— ๋ฐ์ดํ„ฐ๋ฅผ Excel๋กœ ๋‚ด๋ณด๋‚ด๊ธฐ + */ +export async function exportPivotToExcel( + pivotResult: PivotResult, + fields: PivotFieldConfig[], + totals: PivotTotalsConfig, + options: ExportOptions = {} +): Promise { + const { + fileName = "pivot_export", + sheetName = "Pivot", + title, + includeHeaders = true, + includeTotals = true, + } = options; + + // ํ•„๋“œ ๋ถ„๋ฅ˜ + const rowFields = fields + .filter((f) => f.area === "row" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + + // ๋ฐ์ดํ„ฐ ๋ฐฐ์—ด ์ƒ์„ฑ + const data: any[][] = []; + + // ์ œ๋ชฉ ์ถ”๊ฐ€ + if (title) { + data.push([title]); + data.push([]); // ๋นˆ ํ–‰ + } + + // ํ—ค๋” ํ–‰ + if (includeHeaders) { + const headerRow: any[] = [ + rowFields.map((f) => f.caption).join(" / ") || "ํ•ญ๋ชฉ", + ]; + + // ์—ด ํ—ค๋” + for (const col of pivotResult.flatColumns) { + headerRow.push(col.caption || "(์ „์ฒด)"); + } + + // ์ด๊ณ„ ํ—ค๋” + if (totals?.showRowGrandTotals && includeTotals) { + headerRow.push("์ด๊ณ„"); + } + + data.push(headerRow); + } + + // ๋ฐ์ดํ„ฐ ํ–‰ + for (const row of pivotResult.flatRows) { + const excelRow: any[] = []; + + // ํ–‰ ํ—ค๋” (๋“ค์—ฌ์“ฐ๊ธฐ ํฌํ•จ) + const indent = " ".repeat(row.level); + excelRow.push(indent + row.caption); + + // ๋ฐ์ดํ„ฐ ์…€ + for (const col of pivotResult.flatColumns) { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = pivotResult.dataMatrix.get(cellKey); + + if (values && values.length > 0) { + excelRow.push(values[0].value); + } else { + excelRow.push(""); + } + } + + // ํ–‰ ์ด๊ณ„ + if (totals?.showRowGrandTotals && includeTotals) { + const rowTotal = pivotResult.grandTotals.row.get(pathToKey(row.path)); + if (rowTotal && rowTotal.length > 0) { + excelRow.push(rowTotal[0].value); + } else { + excelRow.push(""); + } + } + + data.push(excelRow); + } + + // ์—ด ์ด๊ณ„ ํ–‰ + if (totals?.showColumnGrandTotals && includeTotals) { + const totalRow: any[] = ["์ด๊ณ„"]; + + for (const col of pivotResult.flatColumns) { + const colTotal = pivotResult.grandTotals.column.get(pathToKey(col.path)); + if (colTotal && colTotal.length > 0) { + totalRow.push(colTotal[0].value); + } else { + totalRow.push(""); + } + } + + // ๋Œ€์ดํ•ฉ + if (totals?.showRowGrandTotals) { + const grandTotal = pivotResult.grandTotals.grand; + if (grandTotal && grandTotal.length > 0) { + totalRow.push(grandTotal[0].value); + } else { + totalRow.push(""); + } + } + + data.push(totalRow); + } + + // ์›Œํฌ์‹œํŠธ ์ƒ์„ฑ + const worksheet = XLSX.utils.aoa_to_sheet(data); + + // ์ปฌ๋Ÿผ ๋„ˆ๋น„ ์„ค์ • + const colWidths: XLSX.ColInfo[] = []; + const maxCols = data.reduce((max, row) => Math.max(max, row.length), 0); + for (let i = 0; i < maxCols; i++) { + colWidths.push({ wch: i === 0 ? 25 : 15 }); + } + worksheet["!cols"] = colWidths; + + // ์›Œํฌ๋ถ ์ƒ์„ฑ + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); + + // ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ + XLSX.writeFile(workbook, `${fileName}.xlsx`); +} + +/** + * Drill Down ๋ฐ์ดํ„ฐ๋ฅผ Excel๋กœ ๋‚ด๋ณด๋‚ด๊ธฐ + */ +export async function exportDrillDownToExcel( + data: any[], + columns: { field: string; caption: string }[], + options: ExportOptions = {} +): Promise { + const { + fileName = "drilldown_export", + sheetName = "Data", + title, + } = options; + + // ๋ฐ์ดํ„ฐ ๋ฐฐ์—ด ์ƒ์„ฑ + const sheetData: any[][] = []; + + // ์ œ๋ชฉ + if (title) { + sheetData.push([title]); + sheetData.push([]); // ๋นˆ ํ–‰ + } + + // ํ—ค๋” + const headerRow = columns.map((col) => col.caption); + sheetData.push(headerRow); + + // ๋ฐ์ดํ„ฐ + for (const row of data) { + const dataRow = columns.map((col) => row[col.field] ?? ""); + sheetData.push(dataRow); + } + + // ์›Œํฌ์‹œํŠธ ์ƒ์„ฑ + const worksheet = XLSX.utils.aoa_to_sheet(sheetData); + + // ์ปฌ๋Ÿผ ๋„ˆ๋น„ ์„ค์ • + const colWidths: XLSX.ColInfo[] = columns.map(() => ({ wch: 15 })); + worksheet["!cols"] = colWidths; + + // ์›Œํฌ๋ถ ์ƒ์„ฑ + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); + + // ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ + XLSX.writeFile(workbook, `${fileName}.xlsx`); +} diff --git a/frontend/lib/registry/components/pivot-grid/utils/index.ts b/frontend/lib/registry/components/pivot-grid/utils/index.ts index f832187e..2c0a83d6 100644 --- a/frontend/lib/registry/components/pivot-grid/utils/index.ts +++ b/frontend/lib/registry/components/pivot-grid/utils/index.ts @@ -1,4 +1,6 @@ export * from "./aggregation"; export * from "./pivotEngine"; +export * from "./exportExcel"; +export * from "./conditionalFormat"; diff --git a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts index 18113066..4d3fecfd 100644 --- a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts +++ b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts @@ -12,6 +12,7 @@ import { PivotCellValue, DateGroupInterval, AggregationType, + SummaryDisplayMode, } from "../types"; import { aggregate, formatNumber, formatDate } from "./aggregation"; @@ -418,6 +419,185 @@ function getColumnLeaves(nodes: PivotHeaderNode[]): string[][] { return leaves; } +// ==================== Summary Display Mode ์ ์šฉ ==================== + +/** + * Summary Display Mode์— ๋”ฐ๋ฅธ ๊ฐ’ ๋ณ€ํ™˜ + */ +function applyDisplayMode( + value: number, + displayMode: SummaryDisplayMode | undefined, + rowTotal: number, + columnTotal: number, + grandTotal: number, + prevValue: number | null, + runningTotal: number, + format?: PivotFieldConfig["format"] +): { value: number; formattedValue: string } { + if (!displayMode || displayMode === "absoluteValue") { + return { + value, + formattedValue: formatNumber(value, format), + }; + } + + let resultValue: number; + let formatOverride: PivotFieldConfig["format"] | undefined; + + switch (displayMode) { + case "percentOfRowTotal": + resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "percentOfColumnTotal": + resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "percentOfGrandTotal": + resultValue = grandTotal === 0 ? 0 : (value / grandTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "percentOfRowGrandTotal": + resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "percentOfColumnGrandTotal": + resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "runningTotalByRow": + case "runningTotalByColumn": + resultValue = runningTotal; + break; + + case "differenceFromPrevious": + resultValue = prevValue === null ? 0 : value - prevValue; + break; + + case "percentDifferenceFromPrevious": + resultValue = prevValue === null || prevValue === 0 + ? 0 + : ((value - prevValue) / Math.abs(prevValue)) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + default: + resultValue = value; + } + + return { + value: resultValue, + formattedValue: formatNumber(resultValue, formatOverride || format), + }; +} + +/** + * ๋ฐ์ดํ„ฐ ๋งคํŠธ๋ฆญ์Šค์— Summary Display Mode ์ ์šฉ + */ +function applyDisplayModeToMatrix( + matrix: Map, + dataFields: PivotFieldConfig[], + flatRows: PivotFlatRow[], + flatColumnLeaves: string[][], + rowTotals: Map, + columnTotals: Map, + grandTotals: PivotCellValue[] +): Map { + // displayMode๊ฐ€ ์žˆ๋Š” ๋ฐ์ดํ„ฐ ํ•„๋“œ๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ + const hasDisplayMode = dataFields.some( + (df) => df.summaryDisplayMode || df.showValuesAs + ); + if (!hasDisplayMode) return matrix; + + const newMatrix = new Map(); + + // ๋ˆ„๊ณ„๋ฅผ ์œ„ํ•œ ์ถ”์  (ํ–‰๋ณ„, ์—ด๋ณ„) + const rowRunningTotals: Map = new Map(); // fieldIndex -> ๋ˆ„๊ณ„ + const colRunningTotals: Map> = new Map(); // colKey -> fieldIndex -> ๋ˆ„๊ณ„ + + // ํ–‰ ์ˆœ์„œ๋Œ€๋กœ ์ฒ˜๋ฆฌ + for (const row of flatRows) { + // ์ด์ „ ์—ด ๊ฐ’ ์ถ”์  (์ฐจ์ด ๊ณ„์‚ฐ์šฉ) + const prevColValues: (number | null)[] = dataFields.map(() => null); + + for (let colIdx = 0; colIdx < flatColumnLeaves.length; colIdx++) { + const colPath = flatColumnLeaves[colIdx]; + const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`; + const values = matrix.get(cellKey); + + if (!values) { + newMatrix.set(cellKey, []); + continue; + } + + const rowKey = pathToKey(row.path); + const colKey = pathToKey(colPath); + + // ์ดํ•ฉ ๊ฐ€์ ธ์˜ค๊ธฐ + const rowTotal = rowTotals.get(rowKey); + const colTotal = columnTotals.get(colKey); + + const newValues: PivotCellValue[] = values.map((val, fieldIdx) => { + const dataField = dataFields[fieldIdx]; + const displayMode = dataField.summaryDisplayMode || dataField.showValuesAs; + + if (!displayMode || displayMode === "absoluteValue") { + prevColValues[fieldIdx] = val.value; + return val; + } + + // ๋ˆ„๊ณ„ ๊ณ„์‚ฐ + // ํ–‰ ๋ฐฉํ–ฅ ๋ˆ„๊ณ„ + if (!rowRunningTotals.has(rowKey)) { + rowRunningTotals.set(rowKey, dataFields.map(() => 0)); + } + const rowRunning = rowRunningTotals.get(rowKey)!; + rowRunning[fieldIdx] += val.value || 0; + + // ์—ด ๋ฐฉํ–ฅ ๋ˆ„๊ณ„ + if (!colRunningTotals.has(colKey)) { + colRunningTotals.set(colKey, new Map()); + } + const colRunning = colRunningTotals.get(colKey)!; + if (!colRunning.has(fieldIdx)) { + colRunning.set(fieldIdx, 0); + } + colRunning.set(fieldIdx, (colRunning.get(fieldIdx) || 0) + (val.value || 0)); + + const result = applyDisplayMode( + val.value || 0, + displayMode, + rowTotal?.[fieldIdx]?.value || 0, + colTotal?.[fieldIdx]?.value || 0, + grandTotals[fieldIdx]?.value || 0, + prevColValues[fieldIdx], + displayMode === "runningTotalByRow" + ? rowRunning[fieldIdx] + : colRunning.get(fieldIdx) || 0, + dataField.format + ); + + prevColValues[fieldIdx] = val.value; + + return { + field: val.field, + value: result.value, + formattedValue: result.formattedValue, + }; + }); + + newMatrix.set(cellKey, newValues); + } + } + + return newMatrix; +} + // ==================== ์ดํ•ฉ๊ณ„ ๊ณ„์‚ฐ ==================== /** @@ -584,7 +764,7 @@ export function processPivotData( const flatColumns = flattenColumns(columnHeaders, maxColumnLevel); // ๋ฐ์ดํ„ฐ ๋งคํŠธ๋ฆญ์Šค ์ƒ์„ฑ - const dataMatrix = buildDataMatrix( + let dataMatrix = buildDataMatrix( filteredData, rowFields, columnFields, @@ -603,6 +783,17 @@ export function processPivotData( flatColumnLeaves ); + // Summary Display Mode ์ ์šฉ + dataMatrix = applyDisplayModeToMatrix( + dataMatrix, + dataFields, + flatRows, + flatColumnLeaves, + grandTotals.row, + grandTotals.column, + grandTotals.grand + ); + return { rowHeaders, columnHeaders, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e4f5a1fd..f0ef7c70 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -59,6 +59,7 @@ "date-fns": "^4.1.0", "docx": "^9.5.1", "docx-preview": "^0.3.6", + "exceljs": "^4.4.0", "html-to-image": "^1.11.13", "html2canvas": "^1.4.1", "isomorphic-dompurify": "^2.28.0", @@ -542,6 +543,47 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -6963,6 +7005,59 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -7158,6 +7253,12 @@ "dev": true, "license": "MIT" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -7225,7 +7326,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/base64-arraybuffer": { @@ -7266,6 +7366,15 @@ "require-from-string": "^2.0.2" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -7275,6 +7384,68 @@ "node": "*" } }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bluebird": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", @@ -7285,7 +7456,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -7329,6 +7499,32 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/c12": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", @@ -7501,6 +7697,18 @@ "node": ">=0.8" } }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7665,11 +7873,39 @@ "node": ">= 10" } }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/concaveman": { @@ -7731,6 +7967,33 @@ "node": ">=0.8" } }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -8323,6 +8586,12 @@ "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", "license": "MIT" }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -8605,6 +8874,15 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, "node_modules/earcut": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", @@ -8639,6 +8917,15 @@ "node": ">=14" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -9338,6 +9625,61 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/exceljs/node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/exceljs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/exit-on-epipe": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz", @@ -9377,6 +9719,19 @@ "node": ">=8.0.0" } }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -9586,6 +9941,34 @@ "node": ">=0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -9773,6 +10156,27 @@ "giget": "dist/cli.mjs" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -9847,7 +10251,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -10121,6 +10524,17 @@ "node": ">=0.8.19" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -10843,6 +11257,18 @@ "node": ">=0.10" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", @@ -11142,6 +11568,12 @@ "uc.micro": "^2.0.0" } }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -11158,6 +11590,73 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -11165,6 +11664,18 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -11386,7 +11897,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -11399,12 +11909,23 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11557,6 +12078,15 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/nypm": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", @@ -11707,6 +12237,15 @@ "dev": true, "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/option": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", @@ -12829,6 +13368,36 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -13086,6 +13655,19 @@ "node": ">= 0.8.15" } }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/robust-predicates": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz", @@ -13891,6 +14473,36 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -14032,6 +14644,15 @@ "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -14107,6 +14728,15 @@ "node": ">=20" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/troika-three-text": { "version": "0.52.4", "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", @@ -14402,6 +15032,24 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -14754,6 +15402,12 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", @@ -14974,6 +15628,55 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/zod": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", diff --git a/frontend/package.json b/frontend/package.json index e9cf087c..1dc6c6fe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -67,6 +67,7 @@ "date-fns": "^4.1.0", "docx": "^9.5.1", "docx-preview": "^0.3.6", + "exceljs": "^4.4.0", "html-to-image": "^1.11.13", "html2canvas": "^1.4.1", "isomorphic-dompurify": "^2.28.0",